|
| 1 | +package fixture.compose |
| 2 | + |
| 3 | +import androidx.compose.runtime.MutableState |
| 4 | +import androidx.compose.runtime.snapshots.Snapshot |
| 5 | +import androidx.compose.ui.ImageComposeScene |
| 6 | +import androidx.compose.ui.unit.Density |
| 7 | +import python.multiplatform.compose.PythonContent |
| 8 | +import python.multiplatform.ffi.Python3 |
| 9 | +import python.multiplatform.ffi.pythonx.PythonxAdapter |
| 10 | +import python.multiplatform.ffi.upcall.PythonProxySource |
| 11 | +import python.multiplatform.ffi.upcall.UpcallBootstrap |
| 12 | +import python.multiplatform.generated.FunctionTable |
| 13 | +import python.multiplatform.generated.artifacts.ArtifactTable |
| 14 | +import python.multiplatform.reflection.HandleTable |
| 15 | +import python.multiplatform.reflection.UpcallTable |
| 16 | +import kotlin.test.AfterTest |
| 17 | +import kotlin.test.BeforeTest |
| 18 | +import kotlin.test.Test |
| 19 | +import kotlin.test.assertEquals |
| 20 | +import kotlin.test.assertTrue |
| 21 | + |
| 22 | +/** |
| 23 | + * Issue #69 against the real Compose runtime: a counter declared from Python as |
| 24 | + * `mutableStateOf(0)` -- the walked generic function, its `T` read as `kotlin.Any?` -- holds a Kotlin |
| 25 | + * `Int`, and incrementing it from Python (`counter.value = counter.value + 1`, the walked |
| 26 | + * `MutableState.value` getter and setter) recomposes the root that read it. |
| 27 | + * |
| 28 | + * Modelled on [PythonContentRenderTest]: `ImageComposeScene` has no frame clock, so the test is the |
| 29 | + * clock (`Snapshot.sendApplyNotifications()` and a render), and the root counts its own invocations so |
| 30 | + * a frame can be attributed. |
| 31 | + * |
| 32 | + * ### Red before #69, and what that red says |
| 33 | + * |
| 34 | + * The first statement, `mutableStateOf(0)`, raised `TypeError: mutableStateOf(value: Any): an int |
| 35 | + * cannot be stored in a Kotlin Any?: it would cross as an object handle`. A *regression* reads |
| 36 | + * differently: a root that is not composed again after the write (the state was not a snapshot |
| 37 | + * state, or the read was not observed), or a Kotlin value that is a `Long` rather than an `Int`. |
| 38 | + */ |
| 39 | +class AnySlotScalarRenderTest { |
| 40 | + |
| 41 | + @BeforeTest |
| 42 | + fun installBothProducers() { |
| 43 | + Python3.initialize(silent = true) |
| 44 | + UpcallTable.clear() |
| 45 | + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) |
| 46 | + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } |
| 47 | + PythonProxySource.install() |
| 48 | + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) |
| 49 | + } |
| 50 | + |
| 51 | + @AfterTest |
| 52 | + fun cleanup() { |
| 53 | + Python3.exec( |
| 54 | + """ |
| 55 | + for _asr_name in ('_asr_counter', '_asr_root', '_asr_seen'): |
| 56 | + globals().pop(_asr_name, None) |
| 57 | + """.trimIndent(), |
| 58 | + ) |
| 59 | + UpcallTable.clear() |
| 60 | + } |
| 61 | + |
| 62 | + @Test |
| 63 | + fun aCounterDeclaredAsMutableStateOfZeroAndIncrementedFromPythonRecomposes() { |
| 64 | + Python3.exec( |
| 65 | + """ |
| 66 | + from androidx.compose.runtime import mutableStateOf |
| 67 | + from androidx.compose.material3 import Text |
| 68 | +
|
| 69 | + _asr_counter = mutableStateOf(0) |
| 70 | + _asr_seen = [] |
| 71 | +
|
| 72 | + def _asr_root(): |
| 73 | + _asr_n = _asr_counter.value |
| 74 | + _asr_seen.append(_asr_n) |
| 75 | + Text('count ' + str(_asr_n)) |
| 76 | + """.trimIndent(), |
| 77 | + ) |
| 78 | + assertEquals("0", pyStr("repr(_asr_counter.value)")) |
| 79 | + assertEquals("int", pyStr("type(_asr_counter.value).__name__")) |
| 80 | + assertEquals(0, kotlinValue(), "Kotlin does not hold the Int 0") |
| 81 | + |
| 82 | + val root = Python3.import("__main__").getAttr("_asr_root") |
| 83 | + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { |
| 84 | + PythonContent(root = root) |
| 85 | + } |
| 86 | + try { |
| 87 | + scene.render() |
| 88 | + assertEquals("[0]", pyStr("repr(_asr_seen)"), "the root was not composed once with the initial count") |
| 89 | + |
| 90 | + // An idle frame is not a reason to compose again: nothing here polls. |
| 91 | + scene.render() |
| 92 | + assertEquals("[0]", pyStr("repr(_asr_seen)"), "an idle frame re-ran the root") |
| 93 | + |
| 94 | + Python3.exec("_asr_counter.value = _asr_counter.value + 1") |
| 95 | + assertEquals(1, kotlinValue(), "the increment is not a Kotlin Int 1") |
| 96 | + nextFrame(scene) |
| 97 | + assertEquals("[0, 1]", pyStr("repr(_asr_seen)"), "the increment did not recompose the root") |
| 98 | + |
| 99 | + Python3.exec("_asr_counter.value = _asr_counter.value + 1") |
| 100 | + nextFrame(scene) |
| 101 | + assertEquals("[0, 1, 2]", pyStr("repr(_asr_seen)"), "the second increment did not recompose the root") |
| 102 | + assertTrue( |
| 103 | + pyStr("all(type(_asr_x) is int for _asr_x in _asr_seen)") == "True", |
| 104 | + "a count read during composition was not a Python int: ${pyStr("repr([type(_asr_x) for _asr_x in _asr_seen])")}", |
| 105 | + ) |
| 106 | + } finally { |
| 107 | + scene.close() |
| 108 | + root.close() |
| 109 | + } |
| 110 | + assertEquals(2, kotlinValue()) |
| 111 | + } |
| 112 | + |
| 113 | + /** |
| 114 | + * What the Kotlin `MutableState` behind the Python proxy holds, read on the Kotlin side: the |
| 115 | + * issue's rule is `kotlin.Int` for a value that fits in 32 bits, and an `Int` is not equal to a |
| 116 | + * `Long` of the same value, so this tells the two apart. |
| 117 | + */ |
| 118 | + private fun kotlinValue(): Any? { |
| 119 | + val raw = pyStr("_asr_counter._pm_handle").toLong() |
| 120 | + val state = HandleTable.resolveRaw(raw) as MutableState<*> |
| 121 | + return state.value |
| 122 | + } |
| 123 | + |
| 124 | + private fun nextFrame(scene: ImageComposeScene) { |
| 125 | + Snapshot.sendApplyNotifications() |
| 126 | + scene.render() |
| 127 | + } |
| 128 | + |
| 129 | + private fun pyStr(expression: String): String = Python3.import("__main__").getAttr("__dict__").let { globals -> |
| 130 | + Python3.eval(expression, PY_EVAL_INPUT, globals, globals).toString() |
| 131 | + } |
| 132 | + |
| 133 | + private companion object { |
| 134 | + /** `Py_eval_input`. */ |
| 135 | + const val PY_EVAL_INPUT = 258 |
| 136 | + } |
| 137 | +} |
0 commit comments