diff --git a/docs/SPEC.md b/docs/SPEC.md index 29d92419..aac6e82d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -469,7 +469,9 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. the composition reaches the screen within four frames. Notebook content with no decided or Kotlin 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 + pythonx-compose `develop` wheel on 2026-10-04 (after #146, #168 and pythonx-compose #88), and again + with the notebook's own state spelling (`main.App.messages.getValue()` / `setValue(...)`, #174) + against pythonx-compose f6dd3e2. No CI workflow runs the module yet. ## 7. Threading and builds diff --git a/ksp-fixtures/notebook-e2e/README.md b/ksp-fixtures/notebook-e2e/README.md index 38c65120..f08931ee 100644 --- a/ksp-fixtures/notebook-e2e/README.md +++ b/ksp-fixtures/notebook-e2e/README.md @@ -8,7 +8,9 @@ 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 +`main.App.update(...)`. The UI state is the notebook's own: `remember_saveable` inside the root, +attached as `App.messages`, read and written with `getValue()`/`setValue()` (`.value` is an alias), +which needs `python.multiplatform.compose.RememberSaveableKt` in the bindings. Kotlin parameter names in snake_case; `Color(0xFF...)`; `DefaultIcons.Add` without parentheses; `TextField(state=..., modifier=Modifier.padding(8))`. diff --git a/ksp-fixtures/notebook-e2e/build.gradle.kts b/ksp-fixtures/notebook-e2e/build.gradle.kts index 279eb4c1..8670f33a 100644 --- a/ksp-fixtures/notebook-e2e/build.gradle.kts +++ b/ksp-fixtures/notebook-e2e/build.gradle.kts @@ -90,6 +90,7 @@ pythonBindings { "androidx.compose.runtime.MutableState", "androidx.compose.foundation.text.input.TextFieldState", "androidx.compose.foundation.text.input.TextFieldStateKt", + "python.multiplatform.compose.RememberSaveableKt", ), ) } 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 5d86d91a..9a299443 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 @@ -91,13 +91,15 @@ class NotebookRootTest { 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") + // Cell 9: the state the root remembered is reachable as main.App.messages. + assertEquals("True", pyStr("hasattr(main.App, 'messages')"), "cell 9: main.App.messages does not exist after the first frame") + // Cell 10. + cell("message_backup = main.App.messages.getValue()") 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'") + cell("main.App.messages.setValue('$CHANGED_MESSAGE')") val (afterWrite, writeFrames) = framesUntil({ scene.render() }) { it.contentEquals(changed) } assertEquals( 0, differing(afterWrite, changed), @@ -106,13 +108,14 @@ class NotebookRootTest { assertEquals(2, pyInt("main.calls['App']"), "the write did not recompose the root exactly once") // Cell 13. - cell("main.messages.value = message_backup") + cell("main.App.messages.setValue(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")) + assertEquals(INITIAL_MESSAGE, pyStr("main.App.messages.getValue()")) + assertEquals(INITIAL_MESSAGE, pyStr("main.App.messages.value"), ".value is not an alias of getValue()") } finally { scene.close() } diff --git a/ksp-fixtures/notebook-e2e/src/desktopTest/python/main.py b/ksp-fixtures/notebook-e2e/src/desktopTest/python/main.py index 2c279f4a..1d56282b 100644 --- a/ksp-fixtures/notebook-e2e/src/desktopTest/python/main.py +++ b/ksp-fixtures/notebook-e2e/src/desktopTest/python/main.py @@ -1,25 +1,19 @@ """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`). +It follows the notebook's own spelling, as pythonx-compose ships it: the screen is *declared* with +`@app` (there is no `main.App.update(...)`), and the UI state is the root's `remember_saveable` +value, which the app attaches to the function itself (`App.messages`), so the notebook reads and +writes it as `main.App.messages.getValue()` / `setValue(...)` (cells 9-13). `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.runtime import Composable, app, remember_saveable from pythonx.compose.material3 import Text INITIAL_MESSAGE = "안녕하세요, Python 앱입니다." -messages = state(INITIAL_MESSAGE) - calls = {"App": 0} @@ -27,4 +21,5 @@ @Composable def App(): calls["App"] += 1 - Text(messages.value) + App.messages = messages = remember_saveable(INITIAL_MESSAGE) + Text(messages.getValue())