From 8d4ffa8ee970a0566e8d6424fb229322be3e1927 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:57:55 +0900 Subject: [PATCH 1/4] Docs: Notebook E2E entry (SPEC B-10, planned, #26) --- docs/SPEC.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/SPEC.md b/docs/SPEC.md index ae73cbc9..8ea2c06b 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -369,6 +369,20 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. either plugin application order. Without that configuration nothing happens and none is created. `Status: implemented` — `GP/TypedPythonStubsWiringTest.kt` (a `ProjectBuilder` project; the toolchain side that consumes the configuration is toolchain#23 and is not exercised here). +- **B-10** pythonx-compose's `UI.ipynb` scenarios run end to end on desktop (#26), in the spellings + pythonx-compose decided: with the pythonx-compose **wheel** installed (a path or version input; no + wheel fails every scenario by name unless `-PnotebookE2e.skip=true` disables them) and the host + drawing `PythonContent("pythonx.compose.runtime", "app_root")` (B-8): `import main` draws the root it + declares; the notebook reads and writes the state the screen shows and the screen follows within four + frames; a cell that redeclares the root with `@app` puts it on screen within four frames with no + update call, and disabling the replacement wiring (a stub `@app`, or a host handed the root once) + leaves the screen unchanged; each practice cell (Text, Button, Card, Icon, Column, Row, Arrangement, + Spacer) draws exactly what the same composables draw from Kotlin; `TextField(state=...)` takes a + Hangul input-method composition with no Python function started and the root not rerun, holds the + composing range in Compose, and the notebook reads the committed text; a write from Python outside + the composition reaches the screen within four frames. Notebook content with no decided or Kotlin + counterpart is listed, not imitated. `Status: planned` — `ksp-fixtures/notebook-e2e/` (tests, and a + README with the cell map, the wheel commands and the gaps); not yet run. ## 7. Threading and builds From 350136eaae04a39da1988f7c14d6e29126a3a82f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:57:55 +0900 Subject: [PATCH 2/4] Test: notebook-e2e fixture runs UI.ipynb scenarios against the pythonx-compose wheel (#26) --- ksp-fixtures/notebook-e2e/README.md | 108 +++++ ksp-fixtures/notebook-e2e/build.gradle.kts | 182 ++++++++ .../kotlin/fixture/notebook/EmptyModifier.kt | 14 + .../fixture/notebook/InputMethodScene.kt | 127 ++++++ .../kotlin/fixture/notebook/NotebookHost.kt | 229 ++++++++++ .../fixture/notebook/NotebookPracticeTest.kt | 402 ++++++++++++++++++ .../fixture/notebook/NotebookRootTest.kt | 232 ++++++++++ .../fixture/notebook/NotebookTextFieldTest.kt | 163 +++++++ .../src/desktopTest/python/main.py | 30 ++ settings.gradle.kts | 4 + 10 files changed, 1491 insertions(+) create mode 100644 ksp-fixtures/notebook-e2e/README.md create mode 100644 ksp-fixtures/notebook-e2e/build.gradle.kts create mode 100644 ksp-fixtures/notebook-e2e/src/desktopMain/kotlin/fixture/notebook/EmptyModifier.kt create mode 100644 ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/InputMethodScene.kt create mode 100644 ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookHost.kt create mode 100644 ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookPracticeTest.kt create mode 100644 ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookRootTest.kt create mode 100644 ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookTextFieldTest.kt create mode 100644 ksp-fixtures/notebook-e2e/src/desktopTest/python/main.py diff --git a/ksp-fixtures/notebook-e2e/README.md b/ksp-fixtures/notebook-e2e/README.md new file mode 100644 index 00000000..9bb5329a --- /dev/null +++ b/ksp-fixtures/notebook-e2e/README.md @@ -0,0 +1,108 @@ +# `:ksp-fixtures:notebook-e2e` — pythonx-compose's `UI.ipynb`, end to end + +Issue #26. A desktop host draws a Python-declared root through `python-multiplatform-compose` +(`PythonContent("pythonx.compose.runtime", "app_root")`, #18), with the **pythonx-compose wheel +installed**, and runs the notebook's scenarios against it. Each scenario is its own test, so a +regression fails under the notebook cell's number. + +The specification is the user's notebook, `pythonx-compose/UI.ipynb` (read in place, never copied). +Where pythonx-compose has since decided a different spelling (its `docs/INTENT.md` §5 and +`docs/SPEC.md`), the tests use the decided one: `@app` and an `app_root` state instead of +`main.App.update(...)`; `.value` instead of `getValue()`/`setValue()`; Kotlin parameter names in +snake_case; `Color(0xFF...)`; `DefaultIcons.Add` without parentheses; +`TextField(state=..., modifier=Modifier.padding(8))`. + +## Building the wheel + +pythonx-compose builds its wheel with setuptools (`pyproject.toml`); its own `tests/test_wheel.py` +builds it with `pip wheel`. From a pythonx-compose checkout, with `setuptools` and `wheel` +installed in the Python you run: + +```bash +cd ../pythonx-compose # or wherever the checkout is +python3 -m pip wheel . --no-deps --no-build-isolation -w ../PythonMultiplatform/.tmp/pythonx-compose-dist +``` + +This leaves `build/` and `pythonx_compose.egg-info/` in the pythonx-compose checkout (both are +git-ignored there). To avoid even that, copy the sources first, as `tests/test_wheel.py` does. The +result is `pythonx_compose--py3-none-any.whl` (version 0.0.1 at the time of writing). + +## Running + +```bash +JAVA_HOME= ./gradlew :ksp-fixtures:notebook-e2e:desktopTest --rerun \ + -PpythonxComposeWheel=.tmp/pythonx-compose-dist --console=plain > .tmp/notebook-e2e.log 2>&1; echo "EXIT=$?" +``` + +| Input | Gradle property | Environment | +|---|---|---| +| the wheel: a `.whl` file, or a directory holding `pythonx_compose-*.whl` (relative paths are from the repository root) | `-PpythonxComposeWheel=` | `PYTHONX_COMPOSE_WHEEL` | +| the version to pick from that directory (or to require of the file) | `-PpythonxComposeVersion=` | `PYTHONX_COMPOSE_VERSION` | +| do not run the scenarios | `-PnotebookE2e.skip=true` | `NOTEBOOK_E2E_SKIP=true` | + +**Without a wheel the tests fail, by name, with a message that says how to give one.** They do not +skip. Only `-PnotebookE2e.skip=true` disables `desktopTest`, and the build log then says that the +scenarios did not run. Which of the two CI wants is CI's decision; no workflow runs this module yet. + +"Installing" is unpacking the pure-Python wheel into `build/pythonx-compose/site-packages` +(`installPythonxComposeWheel`), which is what `pip install --target` does for such a wheel; the +embedded interpreter has no pip of its own. The tests then require `pythonx.compose.__file__` to be +inside that directory, so a stray source checkout on `sys.path` cannot stand in for the wheel. + +## Scenarios + +| Notebook cells | Test | What must hold | +|---|---|---| +| 5 | `NotebookRootTest.cell05_theImportsResolveFromTheInstalledWheel` | the cell's imports (decided spellings) resolve | +| 6–7 | `…cell06to07_importingMainDrawsTheAppItDeclares` | `import main` declares the root; the host draws exactly `Text(INITIAL)`; an idle frame does not rerun it | +| 9–13 | `…cell09to13_theNotebookAndTheScreenShareOneState` | the notebook reads the shown state, writes it, the screen follows; restoring restores the screen | +| 15–16 | `…cell15to16_redeclaringTheRootChangesTheScreenWithNoUpdateCall` | a cell redeclaring the root with `@app` puts it on screen within 4 frames, pixel-equal to Kotlin | +| (negative) | `…negative_withAppStubbedOutTheRedeclarationDoesNotReachTheScreen` | with `@app` stubbed to not write `app_root`, the same observation reports no change | +| (negative) | `…negative_aHostHandedTheRootOnceDoesNotFollowARedeclaration` | with the host given `main.App` instead of the state, the same observation reports no change | +| 19, 20 | `NotebookPracticeTest.cell19_text`, `cell20_textWithAFontSize` | red text; at 30 sp | +| 22, 23 | `…cell22_anEmptyBlackButton`, `cell23_aButtonHoldingText` | black `Button`, empty and with text | +| 25 | `…cell25_aCard` | red `Card` with white text | +| 28, 29, 30 | `…cell28_defaultIconsAddIsAnImageVector`, `cell29_anIcon`, `cell30_anIconInsideAButton` | `DefaultIcons.Add` is an `ImageVector`; `Icon` draws it, alone and in a button | +| 35, 36, 38 | `…cell35_aColumnOfTwoButtons`, `cell36_aColumnAlignedToTheEnd`, `cell38_aRow` | `Column`, `Column(horizontal_alignment=Alignment.Horizontal.End)`, `Row` | +| 37 (signature) | `…cell37_aRowWithAnArrangement` | `Row(horizontal_arrangement=Arrangement.Center)` | +| 40 | `…cell40_aCardSpacedBySpacers` | `Card`/`Column`/`Row` spaced by `Spacer(modifier=Modifier.padding(...))` | +| 31–33 | `NotebookTextFieldTest.cell32to33_aTextFieldKeepsInputMethodCompositionInComposeAndTheNotebookReadsTheResult` | see below | + +Every practice cell is compared **pixel for pixel** with the same composables drawn from Kotlin, so a +pass means the screen shows what the cell declared. + +### The text field (cells 31–33, pythonx-compose #10) + +`TextField(state=remember_text_field_state(""))` is focused by a click (keyboard focus traversal if +the click does not do it), then receives a Hangul input-method sequence — composing ㅎ, 하, 한, then +committing 한 — with a frame after each event. While it types, a `sys.monitoring` counter of every +Python function started (`PY_START`, all threads) must stay at zero and the root must not rerun. +Kotlin's `TextFieldState` must hold the composing text with composing range `0..1` at each step and +`한` with no composing range after the commit; the notebook then reads `fields[-1].text == "한"`. +Finally `set_text_and_place_cursor_at_end("x")` from outside the composition must reach the screen +within 4 frames. + +**How the input method is driven.** `ImageComposeScene` (Compose Multiplatform 1.11.1) cannot take +input-method composition: it builds its scene with a `PlatformContext.Empty` delegate that never +hands out the text field's `PlatformTextInputMethodRequest`, has no parameter to replace it, and its +public input API is `sendKeyEvent` (key presses) and pointer events. `InputMethodScene` therefore +builds the same `CanvasLayersComposeScene` (`@InternalComposeUiApi`) with its own `PlatformContext` +that records the request, and delivers each event the way the desktop window's +`InputMethodSession.inputMethodTextChanged` does in 1.11.1: one `request.editText { commitText(committed, 1); +if (composing.isNotEmpty()) setComposingText(composing, 1) }`. Not exercised: the AWT layer above it +(decoding an `InputMethodEvent` into those two strings), which needs a window. + +## Gaps — notebook content not expressed here + +| Notebook | Why it is not in a test | +|---|---| +| cells 2–3 (`print`, numpy) | not UI | +| cell 5: `remember_saveable`, `DefaultCoroutineScope`, `MainCoroutineScope` | undecided in pythonx-compose (INTENT §4.1, SPEC §8); nothing provides them | +| cell 5/25/35/…: lower-case `modifier`, `modifier=modifier` | INTENT §5.4 replaces it with `modifier=Modifier`. The binder has no conversion for a proxy **class** passed as an argument (`Modifier.empty()` is used only for the class-spelling *method call*, `PythonxAdapter.kt` `_BoundMember`), so `Card(modifier=Modifier)` is not expected to work; the argument is left out, which is the same Kotlin default (`Modifier`). Not run to confirm. | +| cell 22/23/30/35: `corner_radius=` on `Button`; cell 25/40: on `Card` | Kotlin's counterpart is `shape=RoundedCornerShape(...)` from `androidx.compose.foundation.shape`, which this fixture does not walk (its package list is `:ksp-fixtures:compose`'s). Adding `"androidx.compose.foundation.shape"` to `artifactIncludePackages` is the next step; not tried. | +| cell 22/23/25/…: `color=` on `Button`/`Card` | written as Kotlin's `colors=ButtonColors(...)` / `CardColors(...)` constructors. `ButtonDefaults.buttonColors(...)`, the usual Kotlin spelling, is a `@Composable` object function, which the walker declines (SPEC B-1). | +| cell 27: `help(DefaultIcons)` | prints documentation; nothing to assert on screen | +| cells 42, 48: empty; "채팅 화면 디자인하기" | no content | +| cells 44–47: `from ws import client` | a websocket client outside pythonx-compose; marked "재 구현 필요" in the notebook | +| cells 51–56: Llama 3.1 streaming into the UI | needs a model and `main.run_llama3`; the state half (writing `.value` from a loop) is cell 12's mechanism | +| cell 54: `onclick=lambda: {...}` driving a state change | a click from Python reaching a Kotlin callback is `:ksp-fixtures:compose`'s `CallbackDrivenRenderTest`; not repeated here | diff --git a/ksp-fixtures/notebook-e2e/build.gradle.kts b/ksp-fixtures/notebook-e2e/build.gradle.kts new file mode 100644 index 00000000..eeb757b7 --- /dev/null +++ b/ksp-fixtures/notebook-e2e/build.gradle.kts @@ -0,0 +1,182 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/** + * Issue #26: pythonx-compose's `UI.ipynb` scenarios, run end to end against a real host. + * + * The host is `python-multiplatform-compose`'s `PythonContent("pythonx.compose.runtime", "app_root")` + * (#18). The Python side is the **installed pythonx-compose wheel**, not a copy of its sources and + * not a stand-in: every `pythonx.compose.*` import in the tests must resolve to the files the wheel + * unpacked (the tests check `__file__`). The wheel is an input: + * + * | Input | Gradle property | Environment | + * |---|---|---| + * | a `.whl` file, or a directory holding `pythonx_compose-*.whl` | `-PpythonxComposeWheel=` | `PYTHONX_COMPOSE_WHEEL` | + * | which version to pick from that directory (or to require of the file) | `-PpythonxComposeVersion=` | `PYTHONX_COMPOSE_VERSION` | + * | deliberately not running the scenarios | `-PnotebookE2e.skip=true` | `NOTEBOOK_E2E_SKIP=true` | + * + * **No wheel is a failure, not a skip.** Without an input every test fails by name with the message + * below; only the explicit opt-out disables `desktopTest`, and it says so in the build log. CI decides + * which of the two it wants. + * + * "Installing" a pure-Python wheel is unpacking it onto `sys.path`, which is what + * `pip install --target` does for one; the embedded interpreter has no `pip` of its own to run. + * [installPythonxComposeWheel] unpacks it into `build/pythonx-compose/site-packages`. + * + * The rest -- the Compose compiler plugin, the walked packages, the JVM flags -- is + * `:ksp-fixtures:compose`'s configuration, kept identical where it can be so that a difference in + * result is a difference in what the notebook asks for, not in the build. + */ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.jetpack.compose) + alias(libs.plugins.compose.compiler) + id("io.github.thisisthepy.python.multiplatform.bindings") +} + +kotlin { + jvm("desktop") { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(projects.pythonMultiplatform) + } + } + val desktopMain by getting { + dependencies { + implementation(projects.pythonMultiplatformCompose) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(libs.compose.material.icons.core) + implementation(compose.desktop.currentOs) + } + } + val desktopTest by getting { + dependencies { + implementation(libs.kotlin.test) + } + } + } +} + +pythonBindings { + role.set("app") + processor.set(projects.pythonMultiplatformKsp) + + artifactConfiguration.set("desktopCompileClasspath") + artifactSourceSet.set("desktopMain") + // The same list as `:ksp-fixtures:compose` (its build file says what each entry is for). Every + // `pythonx.compose.*` module the notebook imports maps onto one of these Kotlin packages + // (pythonx-compose's `pythonx-map.toml`); a package not walked here is a name pythonx cannot + // resolve, which is a fixture gap, not a pythonx one. + artifactIncludePackages.set( + listOf( + "androidx.compose.material3", + "androidx.compose.foundation.layout", + "androidx.compose.ui.graphics", + "androidx.compose.ui.res", + "androidx.compose.ui", + "androidx.compose.material.icons", + "androidx.compose.runtime.SnapshotStateKt", + "androidx.compose.runtime.State", + "androidx.compose.runtime.MutableState", + "androidx.compose.foundation.text.input.TextFieldState", + "androidx.compose.foundation.text.input.TextFieldStateKt", + ), + ) +} + +// ---- The pythonx-compose wheel --------------------------------------------------------------------- + +val wheelInput: String? = providers.gradleProperty("pythonxComposeWheel") + .orElse(providers.environmentVariable("PYTHONX_COMPOSE_WHEEL")).orNull?.takeIf { it.isNotBlank() } +val wheelVersion: String? = providers.gradleProperty("pythonxComposeVersion") + .orElse(providers.environmentVariable("PYTHONX_COMPOSE_VERSION")).orNull?.takeIf { it.isNotBlank() } +val notebookSkipped: Boolean = providers.gradleProperty("notebookE2e.skip") + .orElse(providers.environmentVariable("NOTEBOOK_E2E_SKIP")).orNull?.toBoolean() == true + +/** The wheel to install, or why there is none. Exactly one of the two is non-null. */ +fun resolveWheel(): Pair { + val howTo = "Build it (ksp-fixtures/notebook-e2e/README.md) and pass -PpythonxComposeWheel= " + + "(or PYTHONX_COMPOSE_WHEEL); to skip the notebook scenarios deliberately pass -PnotebookE2e.skip=true." + val input = wheelInput + ?: return null to "No pythonx-compose wheel was given, so the UI.ipynb scenarios cannot run. $howTo" + val given = rootProject.file(input) + if (!given.exists()) return null to "The pythonx-compose wheel input does not exist: $given. $howTo" + val versionPart = wheelVersion?.let { "pythonx_compose-$it-" } + fun isWheel(f: File) = f.isFile && f.name.startsWith("pythonx_compose-") && f.name.endsWith(".whl") + if (given.isDirectory) { + val candidates = given.listFiles().orEmpty().filter(::isWheel) + .filter { versionPart == null || it.name.startsWith(versionPart) } + .sortedBy { it.name } + return when (candidates.size) { + 1 -> candidates.single() to null + 0 -> null to "No pythonx_compose-${wheelVersion ?: "*"}-*.whl in $given. $howTo" + else -> null to "Several pythonx-compose wheels in $given (${candidates.joinToString { it.name }}); " + + "choose one with -PpythonxComposeVersion= or pass the file itself." + } + } + if (!isWheel(given)) return null to "$given is not a pythonx_compose-*.whl file. $howTo" + if (versionPart != null && !given.name.startsWith(versionPart)) { + return null to "$given is not version $wheelVersion (-PpythonxComposeVersion)." + } + return given to null +} + +val resolvedWheel = resolveWheel() +val pythonxComposeWheel: File? = resolvedWheel.first +val pythonxComposeWheelError: String? = resolvedWheel.second +val pythonxComposeSitePackages = layout.buildDirectory.dir("pythonx-compose/site-packages") + +val installPythonxComposeWheel by tasks.registering(Sync::class) { + group = "verification" + description = "Unpacks the pythonx-compose wheel (-PpythonxComposeWheel) where the notebook E2E tests put it on sys.path." + val wheel = pythonxComposeWheel + enabled = wheel != null + if (wheel != null) from(zipTree(wheel)) + into(pythonxComposeSitePackages) +} + +if (notebookSkipped) { + logger.lifecycle( + "notebook-e2e: desktopTest is DISABLED by -PnotebookE2e.skip=true -- the UI.ipynb scenarios did not run.", + ) +} + +/** + * The same JVM `:ksp-fixtures:compose`'s tests need: JDK 21 with the preview FFM API, a + * `java.library.path` for the extracted `libpython`, and a headless AWT. + */ +tasks.named("desktopTest") { + javaLauncher.set( + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(21)) + }, + ) + jvmArgs("--enable-preview", "-Djava.library.path=.") + systemProperty("java.awt.headless", "true") + + // The application module the notebook imports as `main`. + val appDir = layout.projectDirectory.dir("src/desktopTest/python") + inputs.dir(appDir) + systemProperty("notebookE2e.appDir", appDir.asFile.absolutePath) + + val wheel = pythonxComposeWheel + if (wheel != null) { + dependsOn(installPythonxComposeWheel) + inputs.file(wheel) + systemProperty("notebookE2e.wheel", wheel.absolutePath) + systemProperty("notebookE2e.sitePackages", pythonxComposeSitePackages.get().asFile.absolutePath) + } else { + systemProperty("notebookE2e.wheelError", checkNotNull(pythonxComposeWheelError)) + } + enabled = !notebookSkipped +} diff --git a/ksp-fixtures/notebook-e2e/src/desktopMain/kotlin/fixture/notebook/EmptyModifier.kt b/ksp-fixtures/notebook-e2e/src/desktopMain/kotlin/fixture/notebook/EmptyModifier.kt new file mode 100644 index 00000000..a1c4509f --- /dev/null +++ b/ksp-fixtures/notebook-e2e/src/desktopMain/kotlin/fixture/notebook/EmptyModifier.kt @@ -0,0 +1,14 @@ +package fixture.notebook + +import androidx.compose.ui.Modifier + +/** + * The one line of Kotlin pythonx-compose asks an application for: where an empty `Modifier` comes from. + * + * `Modifier` as a Kotlin expression is `Modifier.Companion`, an object, and the walker binds functions, + * so the empty modifier every `Modifier.padding(8)` chain starts from has no bound name of its own. + * pythonx-compose's `pythonx/compose/ui/modifier.py` says what to do about it: supply a zero-argument + * function returning `Modifier` and register its name with `install(...)`. KSP binds this one as + * `fixture.notebook.emptyModifier`; `NotebookHost` registers it. + */ +fun emptyModifier(): Modifier = Modifier diff --git a/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/InputMethodScene.kt b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/InputMethodScene.kt new file mode 100644 index 00000000..5f85afb3 --- /dev/null +++ b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/InputMethodScene.kt @@ -0,0 +1,127 @@ +@file:OptIn(InternalComposeUiApi::class, ExperimentalComposeUiApi::class) + +package fixture.notebook + +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.asComposeCanvas +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.platform.PlatformContext +import androidx.compose.ui.platform.PlatformTextInputMethodRequest +import androidx.compose.ui.platform.WindowInfo +import androidx.compose.ui.scene.CanvasLayersComposeScene +import androidx.compose.ui.scene.ComposeScene +import androidx.compose.ui.text.input.TextEditingScope +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import org.jetbrains.skia.Image +import org.jetbrains.skia.Surface + +/** + * An offscreen scene that, unlike `ImageComposeScene`, lets a test act as the **input method**. + * + * ### Why not `ImageComposeScene` + * + * In Compose Multiplatform 1.11.1 `ImageComposeScene` builds its `ComposeScene` with + * `CanvasLayersComposeScene(..., platformContext = )` (read from + * `ImageComposeScene.class` and `ImageComposeScene$_platformContext$1.class` in `ui-desktop-1.11.1.jar`). + * `PlatformContext.Empty` never hands out the `PlatformTextInputMethodRequest` a focused text field + * starts an input session with, and `ImageComposeScene` has no parameter to replace the context. Its + * public API offers `sendKeyEvent` only -- key presses, not input-method composition. + * + * ### What this does instead + * + * The same `CanvasLayersComposeScene` `ImageComposeScene` wraps (`@InternalComposeUiApi`), drawn into a + * Skia raster surface exactly as `ImageComposeScene.render` does, with a [PlatformContext] whose + * `startInputMethod` records the request a focused field starts. [inputMethodEvent] then delivers an + * input-method event the way the desktop window does: `InputMethodSession.inputMethodTextChanged` in + * the same jar turns an AWT `InputMethodEvent` into one `request.editText { ... }` call that does + * `commitText(committed, 1)` and then, if there is composing text, `setComposingText(composing, 1)`. + * That is reproduced here call for call. What is *not* exercised is the AWT layer above it (decoding an + * `InputMethodEvent`'s attributed text into those two strings), which needs a window. + */ +internal class InputMethodScene( + private val width: Int, + private val height: Int, + content: @Composable () -> Unit, +) : AutoCloseable { + + private val requests = ArrayList() + + /** The input session a focused text field started, if one is open. */ + val activeRequest: PlatformTextInputMethodRequest? get() = requests.lastOrNull() + + private val focusedWindow = object : WindowInfo { + override val isWindowFocused: Boolean get() = true + override val containerSize: IntSize get() = IntSize(width, height) + } + + private val context = object : PlatformContext by PlatformContext.Empty() { + override val windowInfo: WindowInfo get() = focusedWindow + + override suspend fun startInputMethod(request: PlatformTextInputMethodRequest): Nothing { + requests += request + try { + awaitCancellation() + } finally { + requests -= request + } + } + } + + val scene: ComposeScene = CanvasLayersComposeScene( + Density(1f), + LayoutDirection.Ltr, + IntSize(width, height), + Dispatchers.Unconfined, + context, + ) + + private val surface = Surface.makeRasterN32Premul(width, height) + + init { + scene.setContent(content) + } + + /** `ImageComposeScene.render()`'s body: clear, draw the scene at time zero, snapshot. */ + fun render(): Image { + surface.canvas.clear(0) + scene.render(surface.canvas.asComposeCanvas(), 0L) + return surface.makeImageSnapshot() + } + + /** A primary-button click at [at], as `ImageComposeScene.sendPointerEvent` delivers one. */ + fun click(at: Offset) { + scene.sendPointerEvent(PointerEventType.Move, at) + scene.sendPointerEvent(PointerEventType.Press, at) + scene.sendPointerEvent(PointerEventType.Release, at) + } + + /** Keyboard-style focus traversal into the first focusable, the fallback when a click did not focus. */ + fun focusFirst(): Boolean = scene.focusManager.takeFocus(FocusDirection.Next) + + /** + * One input-method event: [committed] text committed and [composing] text left in composition -- + * the two strings the desktop window reads from an AWT `InputMethodEvent`. + */ + fun inputMethodEvent(committed: String, composing: String) { + val request = checkNotNull(activeRequest) { "no text input session is open: the field is not focused" } + val edit = checkNotNull(request.editText) { "the input session offers no editText" } + val event: TextEditingScope.() -> Unit = { + commitText(committed, 1) + if (composing.isNotEmpty()) setComposingText(composing, 1) + } + edit(event) + } + + override fun close() { + scene.close() + surface.close() + } +} diff --git a/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookHost.kt b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookHost.kt new file mode 100644 index 00000000..7c9e52c2 --- /dev/null +++ b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookHost.kt @@ -0,0 +1,229 @@ +package fixture.notebook + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Image +import python.multiplatform.compose.PythonContent +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.fail + +/** + * The host the notebook talks to: one interpreter, the pythonx-compose **wheel** on `sys.path`, and a + * scene drawing `PythonContent("pythonx.compose.runtime", "app_root")` -- the line pythonx-compose's + * SPEC S5.4 says a host is configured with. + * + * The interpreter and the upcall table are installed once per test JVM and never cleared: a + * `pythonx.compose.*` module keeps the names it resolved in its own dict, and reinstalling the table + * would invalidate the proxies behind them. What a notebook session would carry between cells is reset + * per test instead ([install] calls [reset]): a fresh `app_root` state and a fresh `import main`. + * + * Python code runs in `__main__`, as a notebook kernel's cells do. + */ +internal object NotebookHost { + + /** `Py_eval_input`. */ + private const val PY_EVAL_INPUT = 258 + + /** The upper bound on frames a change may take to reach the screen (TextFieldStateRenderTest's). */ + const val MAX_FRAMES = 4 + + private var installed = false + private var installFailure: Throwable? = null + + /** Called from every test's `@BeforeTest`. Fails -- never skips -- when the wheel is not there. */ + fun install() { + installFailure?.let { throw AssertionError("the notebook host could not be installed: ${it.message}", it) } + if (!installed) { + try { + installOnce() + } catch (t: Throwable) { + installFailure = t + throw t + } + installed = true + } + reset() + } + + private fun installOnce() { + System.getProperty("notebookE2e.wheelError")?.let { fail("notebook-e2e: $it") } + val sitePackages = System.getProperty("notebookE2e.sitePackages") + ?: fail("notebook-e2e: no notebookE2e.sitePackages system property; run this through Gradle's desktopTest") + val wheel = System.getProperty("notebookE2e.wheel") ?: "(unknown wheel)" + val appDir = System.getProperty("notebookE2e.appDir") + ?: fail("notebook-e2e: no notebookE2e.appDir system property; run this through Gradle's desktopTest") + if (!java.io.File(sitePackages, "pythonx/compose/runtime/__init__.py").isFile) { + fail("notebook-e2e: $wheel was not unpacked into $sitePackages (no pythonx/compose/runtime/__init__.py there)") + } + + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(setOf("androidx.compose.ui.unit.Dp")) + + Python3.exec( + """ + import sys, os, functools + sys.path[0:0] = [${pyLiteral(sitePackages)}, ${pyLiteral(appDir)}] + + import pythonx.compose + import pythonx.compose.runtime as _nb_rt + _nb_site = os.path.realpath(${pyLiteral(sitePackages)}) + os.sep + for _nb_m in (pythonx.compose, _nb_rt): + assert os.path.realpath(_nb_m.__file__).startswith(_nb_site), \ + _nb_m.__name__ + ' was imported from ' + _nb_m.__file__ + ', not from the installed wheel ' + ${pyLiteral(wheel)} + _nb_real_app = _nb_rt.app + + # `Modifier.padding(8)` starts from an empty Modifier; pythonx-compose asks the application + # for the Kotlin function that makes one (pythonx/compose/ui/modifier.py). + import pythonx.compose.ui.modifier as _nb_modifier + _nb_modifier.install('fixture.notebook.emptyModifier') + + # A counter of every Python function that starts while it is on (sys.monitoring, every + # thread). The callback and both switches are C callables, so turning it on and off from + # Kotlin runs no Python bytecode of its own and records nothing. + _nb_tool = next(_i for _i in range(6) if sys.monitoring.get_tool(_i) is None) + sys.monitoring.use_tool_id(_nb_tool, 'notebook-e2e') + _nb_started = {} + sys.monitoring.register_callback(_nb_tool, sys.monitoring.events.PY_START, _nb_started.__setitem__) + _nb_monitor_on = functools.partial(sys.monitoring.set_events, _nb_tool, sys.monitoring.events.PY_START) + _nb_monitor_off = functools.partial(sys.monitoring.set_events, _nb_tool, 0) + """.trimIndent(), + ) + } + + /** + * What a fresh notebook session sees after cell 6 (`import main`): a new root state, the real `@app` + * (a negative test may have stubbed it), and `main` imported again so its state starts over. + */ + fun reset() { + Python3.exec( + """ + _nb_monitor_off() + _nb_rt.app = _nb_real_app + _nb_rt.__dict__.pop('app_root', None) + sys.modules.pop('main', None) + import main + """.trimIndent(), + ) + } + + /** Runs a notebook cell in `__main__`. */ + fun cell(source: String) = Python3.exec(source.trimIndent()) + + fun pyStr(expression: String): String = Python3.import("__main__").getAttr("__dict__").let { globals -> + Python3.eval(expression, PY_EVAL_INPUT, globals, globals).toString() + } + + fun pyInt(expression: String): Int = pyStr(expression).toInt() + + /** The host: `PythonContent` reading pythonx-compose's root state. */ + fun hostScene(width: Int, height: Int): ImageComposeScene = + ImageComposeScene(width = width, height = height, density = Density(1f)) { + PythonContent(module = "pythonx.compose.runtime", attribute = "app_root") + } + + /** What [content] draws from Kotlin, the control a Python-drawn screen is compared with. */ + fun controlPixels(width: Int, height: Int, content: @Composable () -> Unit): IntArray { + val scene = ImageComposeScene(width = width, height = height, density = Density(1f), content = content) + try { + return pixelsOf(scene.render()) + } finally { + scene.close() + } + } + + /** + * What a window's frame clock does on its own: deliver the snapshot's pending writes, then draw. + * Nothing else -- no host call -- happens between a Python write and the frame that shows it. + */ + fun nextFrame(render: () -> Image): IntArray { + Snapshot.sendApplyNotifications() + return pixelsOf(render()) + } + + /** Up to [MAX_FRAMES] frames, stopping at the first one [done] accepts. Returns it and the count. */ + fun framesUntil(render: () -> Image, done: (IntArray) -> Boolean): Pair { + var frames = 0 + var last: IntArray + do { + last = nextFrame(render) + frames++ + } while (frames < MAX_FRAMES && !done(last)) + return last to frames + } + + /** One redeclaration observed: the screen before the cell, after it, and what Kotlin draws for it. */ + class Redeclaration(val before: IntArray, val after: IntArray, val frames: Int, val expected: IntArray) { + val matchesExpected: Boolean get() = after.contentEquals(expected) + val changedPixels: Int get() = differing(before, after) + val pixelsOffExpected: Int get() = differing(after, expected) + + fun describe(): String = + "after $frames frame(s) $changedPixels pixels changed and $pixelsOffExpected differ from the Kotlin control " + + "(ink before ${inkOf(before)}, after ${inkOf(after)}, control ${inkOf(expected)})" + } + + /** + * Runs [cellSource] -- a cell that redeclares the root -- against a live [scene], then lets up to + * [MAX_FRAMES] frames pass. The positive tests assert [Redeclaration.matchesExpected]; the + * negative ones disable the wiring and assert that this same observation reports no change. + */ + fun redeclare(scene: ImageComposeScene, cellSource: String, expected: IntArray): Redeclaration { + val before = pixelsOf(scene.render()) + cell(cellSource) + val (after, frames) = framesUntil({ scene.render() }) { it.contentEquals(expected) } + return Redeclaration(before, after, frames, expected) + } + + /** + * Runs [block] with the Python-function counter on, and returns what started: one line per code + * object (`qualname file:line`). Empty means no Python function ran. + */ + fun pythonFunctionsStartedDuring(block: () -> Unit): List { + cell("_nb_started.clear()") + val main = Python3.import("__main__") + val on = main.getAttr("_nb_monitor_on") + val off = main.getAttr("_nb_monitor_off") + on().close() + try { + block() + } finally { + off().close() + on.close() + off.close() + } + val count = pyInt("len(_nb_started)") + if (count == 0) return emptyList() + return pyStr( + "'\\n'.join(sorted('%s %s:%d' % (_c.co_qualname, _c.co_filename, _c.co_firstlineno) for _c in _nb_started))", + ).lines() + } + + fun pixelsOf(image: Image): IntArray { + val bitmap = Bitmap.makeFromImage(image) + return IntArray(bitmap.width * bitmap.height) { bitmap.getColor(it % bitmap.width, it / bitmap.width) } + } + + fun inkOf(pixels: IntArray): Int = pixels.count { it != 0 } + + fun differing(a: IntArray, b: IntArray): Int { + check(a.size == b.size) { "frames of different sizes: ${a.size} vs ${b.size}" } + return a.indices.count { a[it] != b[it] } + } + + /** A Python string literal for [s]. */ + private fun pyLiteral(s: String): String = + "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'" +} diff --git a/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookPracticeTest.kt b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookPracticeTest.kt new file mode 100644 index 00000000..2076c805 --- /dev/null +++ b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookPracticeTest.kt @@ -0,0 +1,402 @@ +package fixture.notebook + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import fixture.notebook.NotebookHost.cell +import fixture.notebook.NotebookHost.inkOf +import fixture.notebook.NotebookRootTest.Companion.CELL_05 +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * The notebook's "UI 실습" section (cells 18-40): each practice cell redeclares the root, and the screen + * must then show exactly what the same composables draw from Kotlin. One test per cell, so a regression + * fails under the cell's number. + * + * Every cell is written in the spellings pythonx-compose decided, which differ from the notebook's text + * (pythonx-compose INTENT 2.4 and 5): `@app` instead of `main.App.update(...)`, Kotlin's own parameters + * snake-cased (`on_click`, `image_vector`, `tint`, `content_description`), `Color(0xFF...)` instead of a + * raw integer, `TextUnit(30, TextUnitType.Sp)` for a font size (a `TextUnit` refuses a raw number), + * `ButtonColors(...)` / `CardColors(...)` -- Kotlin's own classes -- for the notebook's `color=`, + * `Spacer(modifier=Modifier.padding(...))` instead of `Spacer(top=...)`, and `DefaultIcons.Add` read + * without parentheses. What the notebook writes and Kotlin has no counterpart for is listed in this + * module's README under "Gaps" and is left out of the cell rather than imitated. + */ +class NotebookPracticeTest { + + @BeforeTest + fun host() = NotebookHost.install() + + /** Cell 19: a red `Text`. */ + @Test + fun cell19_text() = assertCellDraws( + "19", + """ + from pythonx.compose.ui.graphics import Color + + @app + @Composable + def Practice(): + Text("나는 그냥 텍스트랍니다.", color=Color(0xFFFF0000)) + """, + ) { + Text("나는 그냥 텍스트랍니다.", color = Color(0xFFFF0000)) + } + + /** Cell 20: the same, at `font_size=30`. */ + @Test + fun cell20_textWithAFontSize() = assertCellDraws( + "20", + """ + from pythonx.compose.ui.graphics import Color + from pythonx.compose.ui.unit import TextUnit, TextUnitType + + @app + @Composable + def Practice(): + Text("나는 그냥 텍스트랍니다. 좀 더 커져봤어요.", color=Color(0xFFFF0000), font_size=TextUnit(30, TextUnitType.Sp)) + """, + ) { + Text("나는 그냥 텍스트랍니다. 좀 더 커져봤어요.", color = Color(0xFFFF0000), fontSize = 30.sp) + } + + /** Cell 22: a black `Button` with nothing inside. (`corner_radius=50` is a gap, see the README.) */ + @Test + fun cell22_anEmptyBlackButton() = assertCellDraws( + "22", + """ + from pythonx.compose.ui.graphics import Color + from pythonx.compose.material3 import ButtonColors + + @app + @Composable + def Practice(): # 내부 구성요소가 없는 버튼 + Button( + on_click=lambda: None, + colors=ButtonColors(container_color=Color(0xFF000000), content_color=Color(0xFFFFFFFF), + disabled_container_color=Color(0xFF000000), disabled_content_color=Color(0xFFFFFFFF)), + content=lambda: None, + ) + """, + ) { + Button(onClick = {}, colors = blackButton()) {} + } + + /** Cell 23: the same button, holding cell 20's text. */ + @Test + fun cell23_aButtonHoldingText() = assertCellDraws( + "23", + """ + from pythonx.compose.ui.graphics import Color + from pythonx.compose.ui.unit import TextUnit, TextUnitType + from pythonx.compose.material3 import ButtonColors + + @app + @Composable + def Practice(): # 텍스트가 하나 있는 버튼 + Button( + on_click=lambda: None, + colors=ButtonColors(container_color=Color(0xFF000000), content_color=Color(0xFFFFFFFF), + disabled_container_color=Color(0xFF000000), disabled_content_color=Color(0xFFFFFFFF)), + content=lambda: Text("나는 그냥 텍스트랍니다. 좀 더 커져봤어요.", color=Color(0xFFFF0000), font_size=TextUnit(30, TextUnitType.Sp)) + ) + """, + ) { + Button(onClick = {}, colors = blackButton()) { + Text("나는 그냥 텍스트랍니다. 좀 더 커져봤어요.", color = Color(0xFFFF0000), fontSize = 30.sp) + } + } + + /** Cell 25: a red `Card` with white text. (`modifier=modifier` and `corner_radius=20` are gaps.) */ + @Test + fun cell25_aCard() = assertCellDraws( + "25", + """ + from pythonx.compose.ui.graphics import Color + from pythonx.compose.material3 import CardColors + + @app + @Composable + def Practice(): + Card( + colors=CardColors(container_color=Color(0xFFFF0000), content_color=Color(0xFFFFFFFF), + disabled_container_color=Color(0xFFFF0000), disabled_content_color=Color(0xFFFFFFFF)), + content=lambda: Text("나는 그냥 텍스트랍니다.", color=Color(0xFFFFFFFF)) + ) + """, + ) { + Card(colors = CardColors(Color(0xFFFF0000), Color(0xFFFFFFFF), Color(0xFFFF0000), Color(0xFFFFFFFF))) { + Text("나는 그냥 텍스트랍니다.", color = Color(0xFFFFFFFF)) + } + } + + /** Cell 28: `DefaultIcons.Add` is Compose's `ImageVector` -- read, not called. */ + @Test + fun cell28_defaultIconsAddIsAnImageVector() { + cell(CELL_05) + cell( + """ + _nb_icon = DefaultIcons.Add + assert type(_nb_icon)._kotlin_type_name == 'androidx.compose.ui.graphics.vector.ImageVector', type(_nb_icon) + """, + ) + } + + /** Cell 29: a red `Add` icon. The notebook's `icon=`/`color=` are Kotlin's `image_vector=`/`tint=`. */ + @Test + fun cell29_anIcon() = assertCellDraws( + "29", + """ + from pythonx.compose.ui.graphics import Color + + @app + @Composable + def Practice(): # 아이콘 + Icon( + image_vector=DefaultIcons.Add, + content_description=None, + tint=Color(0xFFFF0000) + ) + """, + ) { + Icon(Icons.Default.Add, contentDescription = null, tint = Color(0xFFFF0000)) + } + + /** Cell 30: a yellow `Add` icon inside a black button. */ + @Test + fun cell30_anIconInsideAButton() = assertCellDraws( + "30", + """ + from pythonx.compose.ui.graphics import Color + from pythonx.compose.material3 import ButtonColors + + @app + @Composable + def Practice(): # 버튼 안에 아이콘 + Button( + on_click=lambda: None, + colors=ButtonColors(container_color=Color(0xFF000000), content_color=Color(0xFFFFFFFF), + disabled_container_color=Color(0xFF000000), disabled_content_color=Color(0xFFFFFFFF)), + content=lambda: { + Icon( + image_vector=DefaultIcons.Add, + content_description=None, + tint=Color(0xFFFFFF00) + ) + } + ) + """, + ) { + Button(onClick = {}, colors = blackButton()) { + Icon(Icons.Default.Add, contentDescription = null, tint = Color(0xFFFFFF00)) + } + } + + /** Cell 35: a `Column` of a small and a large button. */ + @Test + fun cell35_aColumnOfTwoButtons() = assertCellDraws( + "35", + """ + from pythonx.compose.ui.graphics import Color + from pythonx.compose.ui.unit import TextUnit, TextUnitType + from pythonx.compose.material3 import ButtonColors + + def _black(): + return ButtonColors(container_color=Color(0xFF000000), content_color=Color(0xFFFFFFFF), + disabled_container_color=Color(0xFF000000), disabled_content_color=Color(0xFFFFFFFF)) + + @app + @Composable + def Practice(): # 세로 정렬 + Column( + content=lambda: { + Button( + on_click=lambda: None, + colors=_black(), + content=lambda: Text("작은 버튼", color=Color(0xFFFFFFFF), font_size=TextUnit(10, TextUnitType.Sp)) + ), + Button( + on_click=lambda: None, + colors=_black(), + content=lambda: Text("큰 버튼", color=Color(0xFFFFFFFF), font_size=TextUnit(30, TextUnitType.Sp)) + ) + } + ) + """, + height = 200, + ) { + Column { + Button(onClick = {}, colors = blackButton()) { Text("작은 버튼", color = Color(0xFFFFFFFF), fontSize = 10.sp) } + Button(onClick = {}, colors = blackButton()) { Text("큰 버튼", color = Color(0xFFFFFFFF), fontSize = 30.sp) } + } + } + + /** Cell 36: a right-aligned `Column`, in the notebook's grouped spelling `Alignment.Horizontal.End`. */ + @Test + fun cell36_aColumnAlignedToTheEnd() = assertCellDraws( + "36", + """ + from pythonx.compose.ui.graphics import Color + from pythonx.compose.ui.unit import TextUnit, TextUnitType + + @app + @Composable + def Practice(): # 세로 정렬 + 오른쪽 정렬 + Column( + horizontal_alignment=Alignment.Horizontal.End, + content=lambda: { + Text("작은 글자", color=Color(0xFF000000), font_size=TextUnit(10, TextUnitType.Sp)), + Text("큰 글자", color=Color(0xFF000000), font_size=TextUnit(30, TextUnitType.Sp)) + } + ) + """, + ) { + Column(horizontalAlignment = Alignment.End) { + Text("작은 글자", color = Color(0xFF000000), fontSize = 10.sp) + Text("큰 글자", color = Color(0xFF000000), fontSize = 30.sp) + } + } + + /** + * Cell 37's signature: the notebook documents `Row(horizontal_arrangement=Arrangement.Center ...)`; + * no code cell passes one, so this is the one Arrangement scenario the notebook states. The row is + * given a width so that centring is visible. + */ + @Test + fun cell37_aRowWithAnArrangement() = assertCellDraws( + "37", + """ + @app + @Composable + def Practice(): + Row( + modifier=Modifier.width(300), + horizontal_arrangement=Arrangement.Center, + content=lambda: { + Text("작은 글자"), + Text("큰 글자") + } + ) + """, + ) { + Row(modifier = Modifier.width(300.dp), horizontalArrangement = Arrangement.Center) { + Text("작은 글자") + Text("큰 글자") + } + } + + /** Cell 38: a `Row` of a small and a large text. */ + @Test + fun cell38_aRow() = assertCellDraws( + "38", + """ + from pythonx.compose.ui.graphics import Color + from pythonx.compose.ui.unit import TextUnit, TextUnitType + + @app + @Composable + def Practice(): # 가로 정렬 + Row( + content=lambda: { + Text("작은 글자", color=Color(0xFF000000), font_size=TextUnit(10, TextUnitType.Sp)), + Text("큰 글자", color=Color(0xFF000000), font_size=TextUnit(30, TextUnitType.Sp)) + } + ) + """, + ) { + Row { + Text("작은 글자", color = Color(0xFF000000), fontSize = 10.sp) + Text("큰 글자", color = Color(0xFF000000), fontSize = 30.sp) + } + } + + /** Cell 40: cell 25's card again, its text spaced by `Spacer`s -- `Spacer(modifier=Modifier.padding(...))`. */ + @Test + fun cell40_aCardSpacedBySpacers() = assertCellDraws( + "40", + """ + from pythonx.compose.ui.graphics import Color + from pythonx.compose.material3 import CardColors + + @app + @Composable + def Practice(): # 아까 카드에 여백 넣어보기 + Card( + colors=CardColors(container_color=Color(0xFF000000), content_color=Color(0xFFFFFFFF), + disabled_container_color=Color(0xFF000000), disabled_content_color=Color(0xFFFFFFFF)), + content=lambda: { + Column( + content=lambda: { + Spacer(modifier=Modifier.padding(top=10)), + Row( + content=lambda: { + Spacer(modifier=Modifier.padding(start=20)), + Text("나는 그냥 텍스트랍니다.", color=Color(0xFFFFFFFF)), + Spacer(modifier=Modifier.padding(end=20)) + } + ), + Spacer(modifier=Modifier.padding(bottom=10)) + } + ) + } + ) + """, + ) { + Card(colors = CardColors(Color(0xFF000000), Color(0xFFFFFFFF), Color(0xFF000000), Color(0xFFFFFFFF))) { + Column { + Spacer(modifier = Modifier.padding(top = 10.dp)) + Row { + Spacer(modifier = Modifier.padding(start = 20.dp)) + Text("나는 그냥 텍스트랍니다.", color = Color(0xFFFFFFFF)) + Spacer(modifier = Modifier.padding(end = 20.dp)) + } + Spacer(modifier = Modifier.padding(bottom = 10.dp)) + } + } + } + + /** + * Runs cell 5 and then [source] against the live host, and requires the screen to equal [control] + * within [NotebookHost.MAX_FRAMES] frames. + */ + private fun assertCellDraws( + cellNumber: String, + source: String, + height: Int = NotebookRootTest.H, + control: @Composable () -> Unit, + ) { + val width = NotebookRootTest.W + val expected = NotebookHost.controlPixels(width, height, control) + assertTrue(inkOf(expected) > 0, "cell $cellNumber: the Kotlin control drew nothing, so the comparison measures nothing") + val scene = NotebookHost.hostScene(width, height) + try { + scene.render() + val seen = NotebookHost.redeclare(scene, CELL_05 + source.trimIndent(), expected) + println("notebook cell $cellNumber: ${seen.describe()}") + assertTrue(seen.matchesExpected, "cell $cellNumber: the screen is not what the cell declares: ${seen.describe()}") + } finally { + scene.close() + } + } +} + +private fun blackButton() = ButtonColors(Color(0xFF000000), Color(0xFFFFFFFF), Color(0xFF000000), Color(0xFFFFFFFF)) diff --git a/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookRootTest.kt b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookRootTest.kt new file mode 100644 index 00000000..2639ad66 --- /dev/null +++ b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookRootTest.kt @@ -0,0 +1,232 @@ +package fixture.notebook + +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Density +import fixture.notebook.NotebookHost.cell +import fixture.notebook.NotebookHost.differing +import fixture.notebook.NotebookHost.framesUntil +import fixture.notebook.NotebookHost.inkOf +import fixture.notebook.NotebookHost.pixelsOf +import fixture.notebook.NotebookHost.pyInt +import fixture.notebook.NotebookHost.pyStr +import python.multiplatform.compose.PythonContent +import python.multiplatform.ffi.Python3 +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The notebook's application-level scenarios (`UI.ipynb` cells 5-16): importing the app draws it, the + * notebook and the screen share state, and **redeclaring the root changes the screen with no update + * call** -- plus the negative checks that show the last one can fail. + * + * Every screen is compared pixel for pixel with the same composables drawn from Kotlin, so "the screen + * changed" means "the screen now shows what the cell declared", not merely "something moved". + */ +class NotebookRootTest { + + @BeforeTest + fun host() = NotebookHost.install() + + /** + * Cell 5's imports, in the spellings pythonx-compose decided (its INTENT section 5): everything the + * notebook imports that has a decided counterpart resolves against the installed wheel. The names it + * leaves out are the gaps in this module's README (`remember_saveable`, the coroutine scopes, the + * lower-case `modifier`). + */ + @Test + fun cell05_theImportsResolveFromTheInstalledWheel() { + cell(CELL_05) + cell( + """ + import pythonx.compose.material3 as _nb_m3 + assert DefaultIcons is not None and Icon is not None and TextField is not None + assert Column is _nb_m3.Column and Row is _nb_m3.Row and Spacer is _nb_m3.Spacer + assert Modifier is not None and Alignment is not None and Arrangement is not None + assert app is _nb_real_app and Composable(len) is len + """, + ) + } + + /** + * Cells 6-7: `import main` declares the app, and the host -- configured once with + * `PythonContent("pythonx.compose.runtime", "app_root")` -- draws it. `main.App` is the root on + * screen. An idle frame does not run it again: nothing polls. + */ + @Test + fun cell06to07_importingMainDrawsTheAppItDeclares() { + val expected = NotebookHost.controlPixels(W, H) { Text(INITIAL_MESSAGE) } + val scene = NotebookHost.hostScene(W, H) + try { + val first = pixelsOf(scene.render()) + assertEquals("True", pyStr("main.App is _nb_rt.app_root.value"), "cell 7: main.App is not the declared root") + assertEquals(1, pyInt("main.calls['App']"), "the declared root was not composed exactly once") + assertTrue(inkOf(expected) > 0, "the Kotlin control drew nothing, so the comparison measures nothing") + assertEquals(0, differing(first, expected), "main.App did not draw what Text(INITIAL_MESSAGE) draws from Kotlin") + + scene.render() + assertEquals(1, pyInt("main.calls['App']"), "an idle frame ran the root again, so something polls") + } finally { + scene.close() + } + } + + /** + * Cells 9-13: the notebook reads the state the screen shows (cell 10), writes it (cell 12) and the + * screen follows, then restores it (cell 13) and the screen is back -- with no call into the host. + * The root runs again because it read the state (a recomposition), not because anything was + * redeclared. The other direction, screen to notebook, is `NotebookTextFieldTest`. + */ + @Test + fun cell09to13_theNotebookAndTheScreenShareOneState() { + val initial = NotebookHost.controlPixels(W, H) { Text(INITIAL_MESSAGE) } + val changed = NotebookHost.controlPixels(W, H) { Text(CHANGED_MESSAGE) } + val scene = NotebookHost.hostScene(W, H) + try { + assertEquals(0, differing(pixelsOf(scene.render()), initial), "the app's first frame is not its initial message") + + // Cells 9-10. + cell("message_backup = main.messages.value") + assertEquals("", pyStr("type(message_backup)"), "the state did not read back as a str") + assertEquals(INITIAL_MESSAGE, pyStr("message_backup"), "cell 10 read something other than what is on screen") + + // Cell 12. + cell("main.messages.value = '$CHANGED_MESSAGE'") + val (afterWrite, writeFrames) = framesUntil({ scene.render() }) { it.contentEquals(changed) } + assertEquals( + 0, differing(afterWrite, changed), + "cell 12: after $writeFrames frame(s) the screen does not show the message the notebook wrote", + ) + assertEquals(2, pyInt("main.calls['App']"), "the write did not recompose the root exactly once") + + // Cell 13. + cell("main.messages.value = message_backup") + val (afterRestore, restoreFrames) = framesUntil({ scene.render() }) { it.contentEquals(initial) } + assertEquals( + 0, differing(afterRestore, initial), + "cell 13: after $restoreFrames frame(s) the screen does not show the restored message", + ) + assertEquals(INITIAL_MESSAGE, pyStr("main.messages.value")) + } finally { + scene.close() + } + } + + /** + * Cells 15-16, the scenario issue #26 is about: a cell redeclares the root and the screen shows it. + * The notebook's `main.App.update(NewUI)` is gone (pythonx-compose INTENT 5.1); `@app` on the new + * function is the whole of it. Between the cell and the frame the test does only what a frame clock + * does ([NotebookHost.nextFrame]). + */ + @Test + fun cell15to16_redeclaringTheRootChangesTheScreenWithNoUpdateCall() { + val expected = NotebookHost.controlPixels(W, H) { NewUiControl() } + val scene = NotebookHost.hostScene(W, H) + try { + scene.render() + val seen = NotebookHost.redeclare(scene, CELL_05 + CELL_15, expected) + println("notebook cell 15: ${seen.describe()}") + assertTrue(inkOf(expected) > 0, "the Kotlin control drew nothing") + assertTrue(seen.matchesExpected, "cell 15: the redeclared root is not on screen: ${seen.describe()}") + assertEquals("True", pyStr("_nb_rt.app_root.value is NewUI"), "@app did not make NewUI the root") + assertEquals(1, pyInt("main.calls['App']"), "the replaced root ran again") + } finally { + scene.close() + } + } + + /** + * **Negative check, Python half.** `@app` replaced by a stub that returns the function without + * writing `app_root` -- the root-replacement wiring disabled on the pythonx side. The same + * observation [cell15to16_redeclaringTheRootChangesTheScreenWithNoUpdateCall] asserts on must now + * report that nothing changed; if it did not, that test could not fail. + */ + @Test + fun negative_withAppStubbedOutTheRedeclarationDoesNotReachTheScreen() { + val expected = NotebookHost.controlPixels(W, H) { NewUiControl() } + val scene = NotebookHost.hostScene(W, H) + try { + scene.render() + cell("_nb_rt.app = lambda root: root") + val seen = NotebookHost.redeclare(scene, CELL_05 + CELL_15, expected) + println("notebook negative (stubbed @app): ${seen.describe()}") + assertFalse(seen.matchesExpected, "with @app stubbed out the screen still showed the redeclared root") + assertEquals(0, seen.changedPixels, "with @app stubbed out the screen changed anyway: ${seen.describe()}") + } finally { + scene.close() + } + } + + /** + * **Negative check, host half.** The host given the root *function* once (`PythonContent(root = + * main.App)`) instead of the state that holds it -- the root-replacement wiring disabled on the + * Kotlin side. A redeclaration then writes a state no composition reads, and the screen stays. + */ + @Test + fun negative_aHostHandedTheRootOnceDoesNotFollowARedeclaration() { + val expected = NotebookHost.controlPixels(W, H) { NewUiControl() } + val fixedRoot = Python3.import("main").getAttr("App") + val scene = ImageComposeScene(width = W, height = H, density = Density(1f)) { PythonContent(root = fixedRoot) } + try { + scene.render() + val seen = NotebookHost.redeclare(scene, CELL_05 + CELL_15, expected) + println("notebook negative (fixed root): ${seen.describe()}") + assertFalse(seen.matchesExpected, "a host that never reads app_root still showed the redeclared root") + assertEquals(0, seen.changedPixels, "a host that never reads app_root changed anyway: ${seen.describe()}") + assertEquals("True", pyStr("_nb_rt.app_root.value is NewUI"), "the redeclaration itself did not happen") + } finally { + scene.close() + fixedRoot.close() + } + } + + internal companion object { + const val W = 360 + const val H = 120 + + /** `main.py`'s `INITIAL_MESSAGE`. */ + const val INITIAL_MESSAGE = "안녕하세요, Python 앱입니다." + + /** Cell 12's text. */ + const val CHANGED_MESSAGE = "UI 상태를 변경해보는 테스트입니다." + + /** + * Cell 5 in the decided spellings: `app` beside `Composable`; no `remember_saveable`, + * `DefaultCoroutineScope`, `MainCoroutineScope` (undecided, pythonx-compose INTENT 4.1 and SPEC + * section 8) and no lower-case `modifier` (INTENT 5.4). + */ + val CELL_05 = """ + from pythonx.compose.runtime import Composable, app + from pythonx.compose.material3 import Icon, DefaultIcons, Text, TextField + from pythonx.compose.material3 import Column, Row, Button, Card, Spacer + from pythonx.compose.ui import Modifier, Alignment + from pythonx.compose.layout import Arrangement + + """.trimIndent() + "\n" + + /** Cells 15-16: `NewUI`, declared with `@app` instead of `main.App.update(NewUI)`; `color=` is `Color(...)` (INTENT 5.5). */ + val CELL_15 = """ + from pythonx.compose.ui.graphics import Color + + @app + @Composable + def NewUI(): + Button( + on_click=lambda: None, + content=lambda: { + Text(f"버튼이 하나만 있는 화면으로 바꿔보자", color=Color(0xFFFFFFFF)) + } + ) + """.trimIndent() + } +} + +@androidx.compose.runtime.Composable +private fun NewUiControl() { + Button(onClick = {}) { Text("버튼이 하나만 있는 화면으로 바꿔보자", color = Color(0xFFFFFFFF)) } +} diff --git a/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookTextFieldTest.kt b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookTextFieldTest.kt new file mode 100644 index 00000000..953cbfb0 --- /dev/null +++ b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookTextFieldTest.kt @@ -0,0 +1,163 @@ +@file:OptIn(ExperimentalFoundationApi::class) + +package fixture.notebook + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.text.TextRange +import fixture.notebook.NotebookHost.cell +import fixture.notebook.NotebookHost.differing +import fixture.notebook.NotebookHost.framesUntil +import fixture.notebook.NotebookHost.inkOf +import fixture.notebook.NotebookHost.nextFrame +import fixture.notebook.NotebookHost.pixelsOf +import fixture.notebook.NotebookHost.pyInt +import fixture.notebook.NotebookHost.pyStr +import fixture.notebook.NotebookRootTest.Companion.CELL_05 +import python.multiplatform.compose.PythonContent +import python.multiplatform.reflection.HandleTable +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Cells 31-33: the text field. The screen writes, the notebook reads (cell 33, "화면에서 값을 변경한 후 + * 출력해보세요" -- change it on screen, then print it). + * + * In the decided spelling (pythonx-compose INTENT 5.8, SPEC S5.5) the field is `TextField(state=...)` + * over a `TextFieldState` from `remember_text_field_state("")`, so Compose owns the text buffer and the + * input method's composing region and **nothing crosses into Python per keystroke**. This is + * pythonx-compose #10's completion criterion, checked here against a real composition: + * + * 1. The cell declares the field; the host draws it; a click focuses it and it opens an input session. + * 2. A Hangul input-method sequence -- ㅎ, 하, 한 composing, then 한 committed -- is delivered the way the + * desktop window delivers one ([InputMethodScene.inputMethodEvent]), a frame after each event. A + * counter of every Python function started ([NotebookHost.pythonFunctionsStartedDuring]) must stay at + * zero across all of it, and the root must not run again. + * 3. Kotlin's own `TextFieldState` holds the composing text with its composing range during the + * sequence, and the committed text with no composing range after it; the notebook then reads + * `field.text == "한"`. + * 4. Outside the composition, `field.set_text_and_place_cursor_at_end("x")` reaches the screen within + * [NotebookHost.MAX_FRAMES] frames. + */ +class NotebookTextFieldTest { + + @BeforeTest + fun host() = NotebookHost.install() + + @Test + fun cell32to33_aTextFieldKeepsInputMethodCompositionInComposeAndTheNotebookReadsTheResult() { + val scene = InputMethodScene(WIDTH, HEIGHT) { + PythonContent(module = "pythonx.compose.runtime", attribute = "app_root") + } + try { + scene.render() + cell(CELL_05 + CELL_32) + framesUntil({ scene.render() }) { pyInt("len(fields)") > 0 } + assertTrue(pyInt("len(fields)") > 0, "cell 32: the redeclared Practice root never composed") + val state = HandleTable.resolveRaw(pyStr("int(fields[-1]._pm_handle)").toLong()) as? TextFieldState + ?: fail("fields[-1] is not a Kotlin TextFieldState: ${pyStr("type(fields[-1])")}") + assertEquals("", state.text.toString()) + + // 1. Focus, by a click on the field; keyboard traversal if the click did not do it. + scene.click(Offset(FIELD_CENTRE_X, FIELD_CENTRE_Y)) + framesUntil({ scene.render() }) { scene.activeRequest != null } + var focusedBy = "click" + if (scene.activeRequest == null) { + focusedBy = "focus traversal (the click did not focus the field)" + scene.focusFirst() + framesUntil({ scene.render() }) { scene.activeRequest != null } + } + if (scene.activeRequest == null) fail("the TextField never opened a text input session, so no input method can reach it") + + // 2-3. The Hangul sequence, with the Python-function counter on. + val practiceRuns = pyInt("practice_calls[0]") + val observed = ArrayList>() + val started = NotebookHost.pythonFunctionsStartedDuring { + for ((committed, composing) in HANGUL_EVENTS) { + scene.inputMethodEvent(committed, composing) + nextFrame { scene.render() } + observed += state.text.toString() to state.composition + } + } + println("notebook cell 32: focused by $focusedBy; IME states $observed; Python functions started: ${started.size}") + assertEquals( + emptyList(), started, + "Python ran while the input method typed into TextField(state=...); every key should stay in Compose", + ) + assertEquals(practiceRuns, pyInt("practice_calls[0]"), "typing re-ran the Practice root") + assertEquals( + listOf( + "ㅎ" to TextRange(0, 1), + "하" to TextRange(0, 1), + "한" to TextRange(0, 1), + "한" to null, + ), + observed, + "the TextFieldState did not hold the composing text and its range, then the committed text", + ) + + // Cell 33: the notebook reads what the screen wrote. + assertEquals("", pyStr("type(fields[-1].text)")) + assertEquals("한", pyStr("fields[-1].text"), "cell 33: the notebook does not read what was typed on screen") + assertEquals("True", pyStr("all(_f.text == '한' for _f in fields)"), "the root saw more than one state") + + // 4. Written from the notebook, outside the composition. + val before = pixelsOf(scene.render()) + cell("fields[-1].set_text_and_place_cursor_at_end('x')") + val (after, frames) = framesUntil({ scene.render() }) { differing(before, it) > 10 } + println("notebook cell 32: set_text_and_place_cursor_at_end moved ${differing(before, after)} px after $frames frame(s)") + assertEquals("x", state.text.toString(), "the write from Python did not reach the Kotlin state") + assertTrue(inkOf(before) > 0, "the field drew nothing") + assertTrue( + differing(before, after) > 10, + "the text written from the notebook did not reach the screen within ${NotebookHost.MAX_FRAMES} frames", + ) + } finally { + scene.close() + } + } + + private companion object { + const val WIDTH = 320 + const val HEIGHT = 80 + + /** Inside material3's `TextField` (280 x 56 by default), drawn at `padding(8)`. */ + const val FIELD_CENTRE_X = 148f + const val FIELD_CENTRE_Y = 36f + + /** (committed, composing) per input-method event: a Hangul IME typing 한 (ㅎ, ㅏ, ㄴ). */ + val HANGUL_EVENTS = listOf( + "" to "ㅎ", + "" to "하", + "" to "한", + "한" to "", + ) + + /** + * Cell 32 in the decided spelling: `TextField(state=..., modifier=Modifier.padding(8))` over a + * `remember_text_field_state("")`, instead of `TextField(text_state=main.App.messages, padding=8)`. + * `fields` and `practice_calls` are the test's handles on what the root made and how often it ran. + */ + val CELL_32 = """ + from pythonx.compose.foundation.text.input import remember_text_field_state + + fields = [] + practice_calls = [0] + + @app + @Composable + def Practice(): # 텍스트 입력란 + practice_calls[0] += 1 + field = remember_text_field_state("") + fields.append(field) + TextField( + state=field, + modifier=Modifier.padding(8) + ) + """.trimIndent() + } +} diff --git a/ksp-fixtures/notebook-e2e/src/desktopTest/python/main.py b/ksp-fixtures/notebook-e2e/src/desktopTest/python/main.py new file mode 100644 index 00000000..2c279f4a --- /dev/null +++ b/ksp-fixtures/notebook-e2e/src/desktopTest/python/main.py @@ -0,0 +1,30 @@ +"""The application module `UI.ipynb` imports as `main` (cell 6), written with pythonx-compose's API. + +The notebook predates two decisions (pythonx-compose `docs/INTENT.md` section 5.1), and this module +follows them rather than the notebook's text: + +- The screen is *declared* with `@app`; there is no `main.App.update(...)`. Redeclaring a root in a + notebook cell is what replaces the screen. +- State is a Compose state read and written through `.value` (`messages.value`), not + `getValue()` / `setValue()`, and it lives at module level (`main.messages`) rather than as an + attribute of the root function (`main.App.messages`). + +`calls` counts how often each root ran, so a test can tell a recomposition from a redeclaration and +an idle frame from a poll. It is the test's instrument, not part of the notebook. +""" + +from pythonx.compose.runtime import Composable, app, state +from pythonx.compose.material3 import Text + +INITIAL_MESSAGE = "안녕하세요, Python 앱입니다." + +messages = state(INITIAL_MESSAGE) + +calls = {"App": 0} + + +@app +@Composable +def App(): + calls["App"] += 1 + Text(messages.value) diff --git a/settings.gradle.kts b/settings.gradle.kts index 4e19f85f..08847bea 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -67,3 +67,7 @@ include(":ksp-fixtures:artifact-valueclass") // ROADMAP §16e's "investigated, not implemented" half. `KlibScanner` reads `.klib` metadata with // `LibraryAbiReader` instead of ASM. include(":ksp-fixtures:klib-artifact") +// pythonx-compose's `UI.ipynb` scenarios end to end (issue #26): the installed pythonx-compose wheel, +// drawn by `PythonContent`. A fixture and not `sample/`, which is user-authored spec material. Its +// desktopTest needs `-PpythonxComposeWheel=`; see its README. +include(":ksp-fixtures:notebook-e2e") From 847ff264fc9503703c736c3bce681fba099c3015 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:52:53 +0900 Subject: [PATCH 3/4] Test: Notebook E2E uses PythonAppView/PythonWidget, builds the wheel with uv, no em-dash (#26) --- docs/SPEC.md | 2 +- ksp-fixtures/notebook-e2e/README.md | 28 +++++++++---------- ksp-fixtures/notebook-e2e/build.gradle.kts | 2 +- .../kotlin/fixture/notebook/NotebookHost.kt | 8 +++--- .../fixture/notebook/NotebookRootTest.kt | 9 +++--- .../fixture/notebook/NotebookTextFieldTest.kt | 4 +-- 6 files changed, 26 insertions(+), 27 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 541be94f..9e7c2037 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -454,7 +454,7 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. Hangul input-method composition with no Python function started and the root not rerun, holds the composing range in Compose, and the notebook reads the committed text; a write from Python outside the composition reaches the screen within four frames. Notebook content with no decided or Kotlin - counterpart is listed, not imitated. `Status: planned` — `ksp-fixtures/notebook-e2e/` (tests, and a + counterpart is listed, not imitated. `Status: planned`: `ksp-fixtures/notebook-e2e/` (tests, and a README with the cell map, the wheel commands and the gaps); not yet run. ## 7. Threading and builds diff --git a/ksp-fixtures/notebook-e2e/README.md b/ksp-fixtures/notebook-e2e/README.md index 9bb5329a..38c65120 100644 --- a/ksp-fixtures/notebook-e2e/README.md +++ b/ksp-fixtures/notebook-e2e/README.md @@ -1,7 +1,7 @@ -# `:ksp-fixtures:notebook-e2e` — pythonx-compose's `UI.ipynb`, end to end +# `:ksp-fixtures:notebook-e2e`: pythonx-compose's `UI.ipynb`, end to end Issue #26. A desktop host draws a Python-declared root through `python-multiplatform-compose` -(`PythonContent("pythonx.compose.runtime", "app_root")`, #18), with the **pythonx-compose wheel +(`PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")`, #18), with the **pythonx-compose wheel installed**, and runs the notebook's scenarios against it. Each scenario is its own test, so a regression fails under the notebook cell's number. @@ -14,18 +14,16 @@ snake_case; `Color(0xFF...)`; `DefaultIcons.Add` without parentheses; ## Building the wheel -pythonx-compose builds its wheel with setuptools (`pyproject.toml`); its own `tests/test_wheel.py` -builds it with `pip wheel`. From a pythonx-compose checkout, with `setuptools` and `wheel` -installed in the Python you run: +Build the wheel with uv from a clean copy of pythonx-compose's `develop`, so nothing is written into +that checkout: ```bash -cd ../pythonx-compose # or wherever the checkout is -python3 -m pip wheel . --no-deps --no-build-isolation -w ../PythonMultiplatform/.tmp/pythonx-compose-dist +mkdir -p .tmp/pxc-src +git -C ../pythonx-compose archive origin/develop | tar -x -C .tmp/pxc-src +(cd .tmp/pxc-src && uv build --wheel --out-dir ../pythonx-compose-dist) ``` -This leaves `build/` and `pythonx_compose.egg-info/` in the pythonx-compose checkout (both are -git-ignored there). To avoid even that, copy the sources first, as `tests/test_wheel.py` does. The -result is `pythonx_compose--py3-none-any.whl` (version 0.0.1 at the time of writing). +The result is `.tmp/pythonx-compose-dist/pythonx_compose--py3-none-any.whl`. ## Running @@ -45,8 +43,8 @@ skip. Only `-PnotebookE2e.skip=true` disables `desktopTest`, and the build log t scenarios did not run. Which of the two CI wants is CI's decision; no workflow runs this module yet. "Installing" is unpacking the pure-Python wheel into `build/pythonx-compose/site-packages` -(`installPythonxComposeWheel`), which is what `pip install --target` does for such a wheel; the -embedded interpreter has no pip of its own. The tests then require `pythonx.compose.__file__` to be +(`installPythonxComposeWheel`), which is all installing a pure-Python wheel amounts to; the embedded +interpreter has no installer of its own. The tests then require `pythonx.compose.__file__` to be inside that directory, so a stray source checkout on `sys.path` cannot stand in for the wheel. ## Scenarios @@ -74,8 +72,8 @@ pass means the screen shows what the cell declared. ### The text field (cells 31–33, pythonx-compose #10) `TextField(state=remember_text_field_state(""))` is focused by a click (keyboard focus traversal if -the click does not do it), then receives a Hangul input-method sequence — composing ㅎ, 하, 한, then -committing 한 — with a frame after each event. While it types, a `sys.monitoring` counter of every +the click does not do it), then receives a Hangul input-method sequence (composing ㅎ, 하, 한, then +committing 한) with a frame after each event. While it types, a `sys.monitoring` counter of every Python function started (`PY_START`, all threads) must stay at zero and the root must not rerun. Kotlin's `TextFieldState` must hold the composing text with composing range `0..1` at each step and `한` with no composing range after the commit; the notebook then reads `fields[-1].text == "한"`. @@ -92,7 +90,7 @@ that records the request, and delivers each event the way the desktop window's if (composing.isNotEmpty()) setComposingText(composing, 1) }`. Not exercised: the AWT layer above it (decoding an `InputMethodEvent` into those two strings), which needs a window. -## Gaps — notebook content not expressed here +## Gaps: notebook content not expressed here | Notebook | Why it is not in a test | |---|---| diff --git a/ksp-fixtures/notebook-e2e/build.gradle.kts b/ksp-fixtures/notebook-e2e/build.gradle.kts index eeb757b7..279eb4c1 100644 --- a/ksp-fixtures/notebook-e2e/build.gradle.kts +++ b/ksp-fixtures/notebook-e2e/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget /** * Issue #26: pythonx-compose's `UI.ipynb` scenarios, run end to end against a real host. * - * The host is `python-multiplatform-compose`'s `PythonContent("pythonx.compose.runtime", "app_root")` + * The host is `python-multiplatform-compose`'s `PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")` * (#18). The Python side is the **installed pythonx-compose wheel**, not a copy of its sources and * not a stand-in: every `pythonx.compose.*` import in the tests must resolve to the files the wheel * unpacked (the tests check `__file__`). The wheel is an input: diff --git a/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookHost.kt b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookHost.kt index 7c9e52c2..68c7609c 100644 --- a/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookHost.kt +++ b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookHost.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.unit.Density import org.jetbrains.skia.Bitmap import org.jetbrains.skia.Image -import python.multiplatform.compose.PythonContent +import python.multiplatform.compose.PythonAppView import python.multiplatform.ffi.Python3 import python.multiplatform.ffi.pythonx.PythonxAdapter import python.multiplatform.ffi.upcall.PythonProxySource @@ -18,7 +18,7 @@ import kotlin.test.fail /** * The host the notebook talks to: one interpreter, the pythonx-compose **wheel** on `sys.path`, and a - * scene drawing `PythonContent("pythonx.compose.runtime", "app_root")` -- the line pythonx-compose's + * scene drawing `PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")` -- the line pythonx-compose's * SPEC S5.4 says a host is configured with. * * The interpreter and the upcall table are installed once per test JVM and never cleared: a @@ -128,10 +128,10 @@ internal object NotebookHost { fun pyInt(expression: String): Int = pyStr(expression).toInt() - /** The host: `PythonContent` reading pythonx-compose's root state. */ + /** The host: `PythonAppView` reading pythonx-compose's root state. */ fun hostScene(width: Int, height: Int): ImageComposeScene = ImageComposeScene(width = width, height = height, density = Density(1f)) { - PythonContent(module = "pythonx.compose.runtime", attribute = "app_root") + PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root") } /** What [content] draws from Kotlin, the control a Python-drawn screen is compared with. */ diff --git a/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookRootTest.kt b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookRootTest.kt index 2639ad66..5d86d91a 100644 --- a/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookRootTest.kt +++ b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookRootTest.kt @@ -12,7 +12,8 @@ import fixture.notebook.NotebookHost.inkOf import fixture.notebook.NotebookHost.pixelsOf import fixture.notebook.NotebookHost.pyInt import fixture.notebook.NotebookHost.pyStr -import python.multiplatform.compose.PythonContent +import python.multiplatform.compose.PythonAppView +import python.multiplatform.compose.PythonWidget import python.multiplatform.ffi.Python3 import kotlin.test.BeforeTest import kotlin.test.Test @@ -55,7 +56,7 @@ class NotebookRootTest { /** * Cells 6-7: `import main` declares the app, and the host -- configured once with - * `PythonContent("pythonx.compose.runtime", "app_root")` -- draws it. `main.App` is the root on + * `PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")` -- draws it. `main.App` is the root on * screen. An idle frame does not run it again: nothing polls. */ @Test @@ -163,7 +164,7 @@ class NotebookRootTest { } /** - * **Negative check, host half.** The host given the root *function* once (`PythonContent(root = + * **Negative check, host half.** The host given the root *function* once (`PythonWidget(root = * main.App)`) instead of the state that holds it -- the root-replacement wiring disabled on the * Kotlin side. A redeclaration then writes a state no composition reads, and the screen stays. */ @@ -171,7 +172,7 @@ class NotebookRootTest { fun negative_aHostHandedTheRootOnceDoesNotFollowARedeclaration() { val expected = NotebookHost.controlPixels(W, H) { NewUiControl() } val fixedRoot = Python3.import("main").getAttr("App") - val scene = ImageComposeScene(width = W, height = H, density = Density(1f)) { PythonContent(root = fixedRoot) } + val scene = ImageComposeScene(width = W, height = H, density = Density(1f)) { PythonWidget(root = fixedRoot) } try { scene.render() val seen = NotebookHost.redeclare(scene, CELL_05 + CELL_15, expected) diff --git a/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookTextFieldTest.kt b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookTextFieldTest.kt index 953cbfb0..fb417526 100644 --- a/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookTextFieldTest.kt +++ b/ksp-fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookTextFieldTest.kt @@ -15,7 +15,7 @@ import fixture.notebook.NotebookHost.pixelsOf import fixture.notebook.NotebookHost.pyInt import fixture.notebook.NotebookHost.pyStr import fixture.notebook.NotebookRootTest.Companion.CELL_05 -import python.multiplatform.compose.PythonContent +import python.multiplatform.compose.PythonAppView import python.multiplatform.reflection.HandleTable import kotlin.test.BeforeTest import kotlin.test.Test @@ -51,7 +51,7 @@ class NotebookTextFieldTest { @Test fun cell32to33_aTextFieldKeepsInputMethodCompositionInComposeAndTheNotebookReadsTheResult() { val scene = InputMethodScene(WIDTH, HEIGHT) { - PythonContent(module = "pythonx.compose.runtime", attribute = "app_root") + PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root") } try { scene.render() From 1c7142206899df7e4a535adac8f90d5d5c5b8e4e Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:02:27 +0900 Subject: [PATCH 4/4] Docs: SPEC B-10 is implemented on desktop: 20 of 20 notebook scenarios pass (#26) --- docs/SPEC.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 84df3182..225fcc29 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -449,7 +449,7 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. - **B-10** pythonx-compose's `UI.ipynb` scenarios run end to end on desktop (#26), in the spellings pythonx-compose decided: with the pythonx-compose **wheel** installed (a path or version input; no wheel fails every scenario by name unless `-PnotebookE2e.skip=true` disables them) and the host - drawing `PythonContent("pythonx.compose.runtime", "app_root")` (B-8): `import main` draws the root it + drawing `PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")` (B-8): `import main` draws the root it declares; the notebook reads and writes the state the screen shows and the screen follows within four frames; a cell that redeclares the root with `@app` puts it on screen within four frames with no update call, and disabling the replacement wiring (a stub `@app`, or a host handed the root once) @@ -458,8 +458,10 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. Hangul input-method composition with no Python function started and the root not rerun, holds the composing range in Compose, and the notebook reads the committed text; a write from Python outside the composition reaches the screen within four frames. Notebook content with no decided or Kotlin - counterpart is listed, not imitated. `Status: planned`: `ksp-fixtures/notebook-e2e/` (tests, and a - README with the cell map, the wheel commands and the gaps); not yet run. + counterpart is listed, not imitated. `Status: implemented` on desktop: `ksp-fixtures/notebook-e2e/` (20 tests, and a + README with the cell map, the wheel commands and the gaps), 20 passed, 0 failed against the + pythonx-compose `develop` wheel on 2026-10-04 (after #146, #168 and pythonx-compose #88). No CI + workflow runs the module yet. ## 7. Threading and builds