diff --git a/jvm/core/src/main/kotlin/com/intuit/playerui/core/bridge/Node.kt b/jvm/core/src/main/kotlin/com/intuit/playerui/core/bridge/Node.kt index 19ce23a7a..7d47bf661 100644 --- a/jvm/core/src/main/kotlin/com/intuit/playerui/core/bridge/Node.kt +++ b/jvm/core/src/main/kotlin/com/intuit/playerui/core/bridge/Node.kt @@ -172,6 +172,13 @@ public fun Node.snapshot(): Map = entries.associate { (key, value) key to when (value) { is Node -> value.snapshot() is Function<*> -> null + is List<*> -> value.map { + when (it) { + is Node -> it.snapshot() + is Function<*> -> null + else -> it + } + } else -> value } } diff --git a/jvm/graaljs/src/test/kotlin/com/intuit/playerui/graaljs/bridge/GraalNodeTest.kt b/jvm/graaljs/src/test/kotlin/com/intuit/playerui/graaljs/bridge/GraalNodeTest.kt index 6a13727a8..e930b83d1 100644 --- a/jvm/graaljs/src/test/kotlin/com/intuit/playerui/graaljs/bridge/GraalNodeTest.kt +++ b/jvm/graaljs/src/test/kotlin/com/intuit/playerui/graaljs/bridge/GraalNodeTest.kt @@ -5,7 +5,9 @@ import com.intuit.playerui.core.bridge.Invokable import com.intuit.playerui.core.bridge.Node import com.intuit.playerui.core.bridge.getInvokable import com.intuit.playerui.core.bridge.getJson +import com.intuit.playerui.core.bridge.snapshot import com.intuit.playerui.core.bridge.toJson +import com.intuit.playerui.core.experimental.ExperimentalPlayerApi import com.intuit.playerui.core.flow.Flow import com.intuit.playerui.graaljs.base.GraalTest import com.intuit.playerui.graaljs.extensions.blockingLock @@ -275,4 +277,69 @@ internal class GraalNodeTest : GraalTest() { ) Assertions.assertEquals("testId", node.getSerializable("flow", Flow.serializer())?.id) } + + @OptIn(ExperimentalPlayerApi::class) + @Test + fun snapshot() = format.context.blockingLock { + val node = buildNodeFromMap( + "title" to "Player Flow", + "count" to 42, + "rate" to 3.5, + "isActive" to true, + "nothing" to null, + "callback" to Invokable { "should be nulled" }, + "settings" to mapOf( + "theme" to "dark", + "nested" to mapOf( + "level" to 2, + "tags" to listOf("a", "b"), + ), + ), + "items" to listOf( + mapOf("id" to "item1", "label" to "First"), + mapOf("id" to "item2", "label" to "Second"), + "plainString", + 100, + null, + mapOf( + "id" to "item3", + "children" to listOf( + mapOf("id" to "child1", "value" to "deep"), + ), + ), + ), + "emptyList" to listOf(), + ) + val snapshot = node.snapshot() + // primitives + Assertions.assertEquals("Player Flow", snapshot["title"]) + Assertions.assertEquals(42, snapshot["count"]) + Assertions.assertEquals(3.5, snapshot["rate"]) + Assertions.assertEquals(true, snapshot["isActive"]) + Assertions.assertNull(snapshot["nothing"]) + // functions become null + Assertions.assertNull(snapshot["callback"]) + // nested nodes are recursively snapshotted into plain maps + val settings = snapshot["settings"] as Map<*, *> + Assertions.assertEquals("dark", settings["theme"]) + val nested = settings["nested"] as Map<*, *> + Assertions.assertEquals(2, nested["level"]) + Assertions.assertEquals(listOf("a", "b"), nested["tags"]) + // lists with mixed content: nodes, primitives, and nulls + val items = snapshot["items"] as List<*> + Assertions.assertEquals(6, items.size) + Assertions.assertEquals(mapOf("id" to "item1", "label" to "First"), items[0]) + Assertions.assertEquals(mapOf("id" to "item2", "label" to "Second"), items[1]) + Assertions.assertEquals("plainString", items[2]) + Assertions.assertEquals(100, items[3]) + Assertions.assertNull(items[4]) + // deeply nested: node inside list with its own list of nodes + val item3 = items[5] as Map<*, *> + Assertions.assertEquals("item3", item3["id"]) + val children = item3["children"] as List<*> + Assertions.assertEquals(1, children.size) + Assertions.assertEquals(mapOf("id" to "child1", "value" to "deep"), children[0]) + // empty list preserved + Assertions.assertEquals(emptyList(), snapshot["emptyList"]) + } } diff --git a/jvm/hermes/src/test/kotlin/com/intuit/playerui/hermes/bridge/HermesNodeTest.kt b/jvm/hermes/src/test/kotlin/com/intuit/playerui/hermes/bridge/HermesNodeTest.kt index 06aff355f..e848dd2e4 100644 --- a/jvm/hermes/src/test/kotlin/com/intuit/playerui/hermes/bridge/HermesNodeTest.kt +++ b/jvm/hermes/src/test/kotlin/com/intuit/playerui/hermes/bridge/HermesNodeTest.kt @@ -5,7 +5,9 @@ import com.intuit.playerui.core.bridge.Invokable import com.intuit.playerui.core.bridge.Node import com.intuit.playerui.core.bridge.getInvokable import com.intuit.playerui.core.bridge.getJson +import com.intuit.playerui.core.bridge.snapshot import com.intuit.playerui.core.bridge.toJson +import com.intuit.playerui.core.experimental.ExperimentalPlayerApi import com.intuit.playerui.core.flow.Flow import com.intuit.playerui.hermes.base.HermesTest import com.intuit.playerui.hermes.extensions.evaluateInJSThreadBlocking @@ -306,4 +308,78 @@ internal class HermesNodeTest : HermesTest() { }.toNode(format) assertEquals("testId", node.getSerializable("flow", Flow.serializer())?.id) } + + @OptIn(ExperimentalPlayerApi::class) + @Test + fun snapshot() { + val node = runtime + .evaluateInJSThreadBlocking { + format.`object` { + set("title", "Player Flow") + set("count", 42) + set("rate", 3.5) + set("isActive", true) + set("nothing", null) + set("callback", Invokable { "should be nulled" }) + set( + "settings", + mapOf( + "theme" to "dark", + "nested" to mapOf( + "level" to 2, + "tags" to listOf("a", "b"), + ), + ), + ) + set( + "items", + listOf( + mapOf("id" to "item1", "label" to "First"), + mapOf("id" to "item2", "label" to "Second"), + "plainString", + 100, + null, + mapOf( + "id" to "item3", + "children" to listOf( + mapOf("id" to "child1", "value" to "deep"), + ), + ), + ), + ) + set("emptyList", listOf()) + } + }.toNode(format) + val snapshot = node.snapshot() + // primitives + assertEquals("Player Flow", snapshot["title"]) + assertEquals(42, snapshot["count"]) + assertEquals(3.5, snapshot["rate"]) + assertEquals(true, snapshot["isActive"]) + assertNull(snapshot["nothing"]) + // functions become null + assertNull(snapshot["callback"]) + // nested nodes are recursively snapshotted into plain maps + val settings = snapshot["settings"] as Map<*, *> + assertEquals("dark", settings["theme"]) + val nested = settings["nested"] as Map<*, *> + assertEquals(2, nested["level"]) + assertEquals(listOf("a", "b"), nested["tags"]) + // lists with mixed content: nodes, primitives, and nulls + val items = snapshot["items"] as List<*> + assertEquals(6, items.size) + assertEquals(mapOf("id" to "item1", "label" to "First"), items[0]) + assertEquals(mapOf("id" to "item2", "label" to "Second"), items[1]) + assertEquals("plainString", items[2]) + assertEquals(100, items[3]) + assertNull(items[4]) + // deeply nested: node inside list with its own list of nodes + val item3 = items[5] as Map<*, *> + assertEquals("item3", item3["id"]) + val children = item3["children"] as List<*> + assertEquals(1, children.size) + assertEquals(mapOf("id" to "child1", "value" to "deep"), children[0]) + // empty list preserved + assertEquals(emptyList(), snapshot["emptyList"]) + } } diff --git a/jvm/j2v8/src/test/kotlin/com/intuit/playerui/j2v8/bridge/V8NodeTest.kt b/jvm/j2v8/src/test/kotlin/com/intuit/playerui/j2v8/bridge/V8NodeTest.kt index fecdf81e6..72d2645b1 100644 --- a/jvm/j2v8/src/test/kotlin/com/intuit/playerui/j2v8/bridge/V8NodeTest.kt +++ b/jvm/j2v8/src/test/kotlin/com/intuit/playerui/j2v8/bridge/V8NodeTest.kt @@ -5,7 +5,9 @@ import com.intuit.playerui.core.bridge.Invokable import com.intuit.playerui.core.bridge.Node import com.intuit.playerui.core.bridge.getInvokable import com.intuit.playerui.core.bridge.getJson +import com.intuit.playerui.core.bridge.snapshot import com.intuit.playerui.core.bridge.toJson +import com.intuit.playerui.core.experimental.ExperimentalPlayerApi import com.intuit.playerui.core.flow.Flow import com.intuit.playerui.j2v8.base.J2V8Test import com.intuit.playerui.j2v8.bridge.serialization.format.v8Object @@ -279,4 +281,69 @@ internal class V8NodeTest : J2V8Test() { ) assertEquals("testId", node.getSerializable("flow", Flow.serializer())?.id) } + + @OptIn(ExperimentalPlayerApi::class) + @Test + fun snapshot() = v8.evaluateInJSThreadBlocking(runtime) { + val node = buildNodeFromMap( + "title" to "Player Flow", + "count" to 42, + "rate" to 3.5, + "isActive" to true, + "nothing" to null, + "callback" to Invokable { "should be nulled" }, + "settings" to mapOf( + "theme" to "dark", + "nested" to mapOf( + "level" to 2, + "tags" to listOf("a", "b"), + ), + ), + "items" to listOf( + mapOf("id" to "item1", "label" to "First"), + mapOf("id" to "item2", "label" to "Second"), + "plainString", + 100, + null, + mapOf( + "id" to "item3", + "children" to listOf( + mapOf("id" to "child1", "value" to "deep"), + ), + ), + ), + "emptyList" to listOf(), + ) + val snapshot = node.snapshot() + // primitives + assertEquals("Player Flow", snapshot["title"]) + assertEquals(42, snapshot["count"]) + assertEquals(3.5, snapshot["rate"]) + assertEquals(true, snapshot["isActive"]) + assertNull(snapshot["nothing"]) + // functions become null + assertNull(snapshot["callback"]) + // nested nodes are recursively snapshotted into plain maps + val settings = snapshot["settings"] as Map<*, *> + assertEquals("dark", settings["theme"]) + val nested = settings["nested"] as Map<*, *> + assertEquals(2, nested["level"]) + assertEquals(listOf("a", "b"), nested["tags"]) + // lists with mixed content: nodes, primitives, and nulls + val items = snapshot["items"] as List<*> + assertEquals(6, items.size) + assertEquals(mapOf("id" to "item1", "label" to "First"), items[0]) + assertEquals(mapOf("id" to "item2", "label" to "Second"), items[1]) + assertEquals("plainString", items[2]) + assertEquals(100, items[3]) + assertNull(items[4]) + // deeply nested: node inside list with its own list of nodes + val item3 = items[5] as Map<*, *> + assertEquals("item3", item3["id"]) + val children = item3["children"] as List<*> + assertEquals(1, children.size) + assertEquals(mapOf("id" to "child1", "value" to "deep"), children[0]) + // empty list preserved + assertEquals(emptyList(), snapshot["emptyList"]) + } }