From 08c71d34fa096171124fa2291a3c21a03abd5394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Peres=20Fran=C3=A7a?= Date: Thu, 22 Sep 2022 14:14:48 -0300 Subject: [PATCH] fix: better rendering logic (#53) * incomplete * important integration tests are passing * replaces class UINode with boolean polymorphic * remove dependent * Implemented lazy initialization for the tree * forEach working * tests are passing * docs * detekt + new test * small change to events * fixes tests --- build.gradle.kts | 6 +- .../com/zup/nimbus/core/regex/FastRegex.kt | 17 + .../kotlin/com/zup/nimbus/core/ActionEvent.kt | 40 + .../kotlin/com/zup/nimbus/core/Nimbus.kt | 122 +- .../com/zup/nimbus/core/ServerDrivenConfig.kt | 43 +- .../com/zup/nimbus/core/ServerDrivenState.kt | 87 ++ .../com/zup/nimbus/core/ServerDrivenView.kt | 34 + .../com/zup/nimbus/core/action/condition.kt | 19 - .../com/zup/nimbus/core/action/index.kt | 26 - .../com/zup/nimbus/core/action/navigation.kt | 72 -- .../com/zup/nimbus/core/action/setContent.kt | 29 - .../com/zup/nimbus/core/action/setState.kt | 16 - .../com/zup/nimbus/core/component/errors.kt | 8 - .../com/zup/nimbus/core/component/forEach.kt | 60 - .../com/zup/nimbus/core/component/if.kt | 20 - .../com/zup/nimbus/core/component/index.kt | 11 - .../com/zup/nimbus/core/component/switch.kt | 25 - .../core/dependency/CommonDependency.kt | 16 + .../zup/nimbus/core/dependency/Dependency.kt | 27 + .../dependency/DependencyUpdateManager.kt | 73 ++ .../zup/nimbus/core/dependency/Dependent.kt | 14 + .../zup/nimbus/core/expression/Expression.kt | 8 + .../com/zup/nimbus/core/expression/Literal.kt | 7 + .../zup/nimbus/core/expression/Operation.kt | 47 + .../nimbus/core/expression/StateReference.kt | 47 + .../nimbus/core/expression/StringTemplate.kt | 44 + .../expression/parser/ExpressionParser.kt | 50 + .../core/expression/parser/LiteralParser.kt | 28 + .../core/expression/parser/OperationParser.kt | 76 ++ .../expression/parser/StateReferenceParser.kt | 43 + .../expression/parser/StringTemplateParser.kt | 27 + .../parser}/automaton.kt | 8 +- .../nimbus/core/network/DefaultHttpClient.kt | 6 +- .../nimbus/core/network/DefaultViewClient.kt | 33 +- .../com/zup/nimbus/core/network/ViewClient.kt | 8 +- .../zup/nimbus/core/network/ViewRequest.kt | 4 +- .../com/zup/nimbus/core/operations/index.kt | 8 - .../com/zup/nimbus/core/regex/FastRegex.kt | 15 + .../com/zup/nimbus/core/render/ActionEvent.kt | 26 - .../com/zup/nimbus/core/render/Renderer.kt | 327 ----- .../nimbus/core/render/ServerDrivenView.kt | 96 -- .../com/zup/nimbus/core/render/action.kt | 85 -- .../com/zup/nimbus/core/render/error.kt | 23 - .../com/zup/nimbus/core/render/expression.kt | 184 --- .../com/zup/nimbus/core/scope/CommonScope.kt | 22 + .../com/zup/nimbus/core/scope/LazilyScoped.kt | 28 + .../kotlin/com/zup/nimbus/core/scope/Scope.kt | 84 ++ .../zup/nimbus/core/scope/StateOnlyScope.kt | 19 + .../zup/nimbus/core/tree/ObservableState.kt | 34 - .../com/zup/nimbus/core/tree/RenderAction.kt | 55 - .../com/zup/nimbus/core/tree/RenderNode.kt | 244 ---- .../nimbus/core/tree/ServerDrivenAction.kt | 21 +- .../zup/nimbus/core/tree/ServerDrivenEvent.kt | 51 + .../zup/nimbus/core/tree/ServerDrivenNode.kt | 38 +- .../zup/nimbus/core/tree/ServerDrivenState.kt | 48 - .../zup/nimbus/core/tree/TreeUpdateMode.kt | 20 - .../nimbus/core/tree/dynamic/DynamicAction.kt | 62 + .../nimbus/core/tree/dynamic/DynamicEvent.kt | 61 + .../tree/dynamic/builder/ActionBuilder.kt | 59 + .../core/tree/dynamic/builder/EventBuilder.kt | 36 + .../core/tree/dynamic/builder/NodeBuilder.kt | 92 ++ .../core/tree/{ => dynamic/builder}/error.kt | 2 +- .../tree/dynamic/container/NodeContainer.kt | 75 ++ .../dynamic/container/PropertyContainer.kt | 160 +++ .../tree/dynamic/container/PropertyCopying.kt | 89 ++ .../core/tree/dynamic/node/DynamicNode.kt | 81 ++ .../core/tree/dynamic/node/ForEachNode.kt | 146 +++ .../nimbus/core/tree/dynamic/node/IfNode.kt | 48 + .../nimbus/core/tree/dynamic/node/RootNode.kt | 26 + .../kotlin/com/zup/nimbus/core/types.kt | 5 + .../com/zup/nimbus/core/ui/UILibrary.kt | 80 ++ .../zup/nimbus/core/ui/UILibraryManager.kt | 76 ++ .../zup/nimbus/core/ui/action/condition.kt | 19 + .../zup/nimbus/core/{ => ui}/action/log.kt | 8 +- .../zup/nimbus/core/ui/action/navigation.kt | 59 + .../core/{ => ui}/action/sendRequest.kt | 21 +- .../com/zup/nimbus/core/ui/action/setState.kt | 35 + .../com/zup/nimbus/core/ui/coreUILibrary.kt | 44 + .../nimbus/core/{ => ui}/operations/array.kt | 21 +- .../nimbus/core/{ => ui}/operations/logic.kt | 23 +- .../nimbus/core/{ => ui}/operations/number.kt | 41 +- .../nimbus/core/{ => ui}/operations/other.kt | 31 +- .../nimbus/core/{ => ui}/operations/string.kt | 31 +- .../kotlin/com/zup/nimbus/core/utils/any.kt | 15 + .../kotlin/com/zup/nimbus/core/utils/json.kt | 2 +- .../com.zup.nimbus.core/EmptyNavigator.kt | 11 - .../kotlin/com.zup.nimbus.core/NodeUtils.kt | 37 +- .../ObservableHttpClient.kt | 1 - .../ObservableNavigator.kt | 20 +- .../kotlin/com.zup.nimbus.core/Page.kt | 12 - .../com.zup.nimbus.core/ViewObserver.kt | 35 - .../integration/ActionObserverTest.kt | 117 +- .../integration/StateResolutionTest.kt | 20 +- .../integration/forEach/ForEachTest.kt | 334 +++--- .../integration/forEach/screens.kt | 100 +- .../integration/ifThenElse/IfTest.kt | 163 ++- .../integration/ifThenElse/screens.kt | 56 +- .../integration/navigation/NavigationTest.kt | 43 +- .../integration/navigation/PreFetchTest.kt | 10 +- .../navigation/screenAssertions.kt | 15 +- .../integration/operations/OperationsTest.kt | 19 +- .../sendRequest/SendRequestTest.kt | 40 +- .../integration/sendRequest/serverMock.kt | 1 + .../integration/setContent/SetContentTest.kt | 83 -- .../integration/setContent/screens.kt | 103 -- .../integration/setState/SetStateTest.kt | 123 +- .../performance/PerformanceTest.kt | 46 +- .../unity/FastRegexTest.kt | 25 + ...rivenStateTest.kt => ServerDrivenState.kt} | 35 +- .../unity/action/ConditionTest.kt | 79 +- .../unity/action/SimpleAction.kt | 14 + .../unity/action/SimpleEvent.kt | 39 + .../unity/expression/expression.kt | 402 +++++++ .../unity/network/DefaultHttpClient.kt | 14 +- .../unity/network/UrlBuilder.kt | 10 +- .../unity/operations/and.kt | 4 +- .../unity/operations/capitalize.kt | 4 +- .../unity/operations/concat.kt | 4 +- .../unity/operations/condition.kt | 4 +- .../unity/operations/contains.kt | 4 +- .../unity/operations/divide.kt | 4 +- .../unity/operations/eq.kt | 4 +- .../unity/operations/gt.kt | 4 +- .../unity/operations/gte.kt | 4 +- .../unity/operations/insert.kt | 4 +- .../unity/operations/isEmpty.kt | 4 +- .../unity/operations/isNull.kt | 4 +- .../unity/operations/length.kt | 5 +- .../unity/operations/lowercase.kt | 4 +- .../unity/operations/lt.kt | 4 +- .../unity/operations/lte.kt | 4 +- .../unity/operations/match.kt | 4 +- .../unity/operations/multiply.kt | 4 +- .../unity/operations/not.kt | 4 +- .../unity/operations/or.kt | 4 +- .../unity/operations/remove.kt | 4 +- .../unity/operations/removeIndex.kt | 4 +- .../unity/operations/replace.kt | 4 +- .../unity/operations/substr.kt | 4 +- .../unity/operations/subtract.kt | 4 +- .../unity/operations/sum.kt | 4 +- .../unity/operations/uppercase.kt | 4 +- .../unity/render/expression.kt | 1053 ----------------- .../unity/tree/NodeBuilder.kt | 22 + .../unity/tree/ObservableStateTest.kt | 132 --- .../unity/tree/RenderNodeTest.kt | 20 - .../com/zup/nimbus/core/regex/FastRegex.kt | 18 +- 147 files changed, 3630 insertions(+), 3808 deletions(-) create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/ActionEvent.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/ServerDrivenState.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/ServerDrivenView.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/action/condition.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/action/index.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/action/navigation.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/action/setContent.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/action/setState.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/component/errors.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/component/forEach.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/component/if.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/component/index.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/component/switch.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/dependency/CommonDependency.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/dependency/Dependency.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/dependency/DependencyUpdateManager.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/dependency/Dependent.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/expression/Expression.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/expression/Literal.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/expression/Operation.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/expression/StateReference.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/expression/StringTemplate.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/ExpressionParser.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/LiteralParser.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/OperationParser.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/StateReferenceParser.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/StringTemplateParser.kt rename src/commonMain/kotlin/com/zup/nimbus/core/{render => expression/parser}/automaton.kt (96%) delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/operations/index.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/render/ActionEvent.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/render/Renderer.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/render/ServerDrivenView.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/render/action.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/render/error.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/render/expression.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/scope/CommonScope.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/scope/LazilyScoped.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/scope/Scope.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/scope/StateOnlyScope.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/ObservableState.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/RenderAction.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/RenderNode.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenEvent.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenState.kt delete mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/TreeUpdateMode.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/DynamicAction.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/DynamicEvent.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/builder/ActionBuilder.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/builder/EventBuilder.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/builder/NodeBuilder.kt rename src/commonMain/kotlin/com/zup/nimbus/core/tree/{ => dynamic/builder}/error.kt (92%) create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/container/NodeContainer.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/container/PropertyContainer.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/container/PropertyCopying.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/DynamicNode.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/ForEachNode.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/IfNode.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/RootNode.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/types.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/ui/UILibrary.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/ui/UILibraryManager.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/ui/action/condition.kt rename src/commonMain/kotlin/com/zup/nimbus/core/{ => ui}/action/log.kt (74%) create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/ui/action/navigation.kt rename src/commonMain/kotlin/com/zup/nimbus/core/{ => ui}/action/sendRequest.kt (80%) create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/ui/action/setState.kt create mode 100644 src/commonMain/kotlin/com/zup/nimbus/core/ui/coreUILibrary.kt rename src/commonMain/kotlin/com/zup/nimbus/core/{ => ui}/operations/array.kt (69%) rename src/commonMain/kotlin/com/zup/nimbus/core/{ => ui}/operations/logic.kt (58%) rename src/commonMain/kotlin/com/zup/nimbus/core/{ => ui}/operations/number.kt (70%) rename src/commonMain/kotlin/com/zup/nimbus/core/{ => ui}/operations/other.kt (78%) rename src/commonMain/kotlin/com/zup/nimbus/core/{ => ui}/operations/string.kt (67%) delete mode 100644 src/commonTest/kotlin/com.zup.nimbus.core/EmptyNavigator.kt delete mode 100644 src/commonTest/kotlin/com.zup.nimbus.core/Page.kt delete mode 100644 src/commonTest/kotlin/com.zup.nimbus.core/ViewObserver.kt delete mode 100644 src/commonTest/kotlin/com.zup.nimbus.core/integration/setContent/SetContentTest.kt delete mode 100644 src/commonTest/kotlin/com.zup.nimbus.core/integration/setContent/screens.kt rename src/commonTest/kotlin/com.zup.nimbus.core/unity/{tree/ServerDrivenStateTest.kt => ServerDrivenState.kt} (71%) create mode 100644 src/commonTest/kotlin/com.zup.nimbus.core/unity/action/SimpleAction.kt create mode 100644 src/commonTest/kotlin/com.zup.nimbus.core/unity/action/SimpleEvent.kt create mode 100644 src/commonTest/kotlin/com.zup.nimbus.core/unity/expression/expression.kt delete mode 100644 src/commonTest/kotlin/com.zup.nimbus.core/unity/render/expression.kt create mode 100644 src/commonTest/kotlin/com.zup.nimbus.core/unity/tree/NodeBuilder.kt delete mode 100644 src/commonTest/kotlin/com.zup.nimbus.core/unity/tree/ObservableStateTest.kt delete mode 100644 src/commonTest/kotlin/com.zup.nimbus.core/unity/tree/RenderNodeTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 9840552..12118ea 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -51,9 +51,9 @@ kotlin { } sourceSets { - val serializationVersion = "1.3.3" - val coroutinesVersion = "1.6.3" - val ktorVersion = "2.0.3" + val serializationVersion = "1.4.0" + val coroutinesVersion = "1.6.4" + val ktorVersion = "2.1.1" val commonMain by getting { dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion") diff --git a/src/androidMain/kotlin/com/zup/nimbus/core/regex/FastRegex.kt b/src/androidMain/kotlin/com/zup/nimbus/core/regex/FastRegex.kt index f62d0f2..60151cf 100644 --- a/src/androidMain/kotlin/com/zup/nimbus/core/regex/FastRegex.kt +++ b/src/androidMain/kotlin/com/zup/nimbus/core/regex/FastRegex.kt @@ -39,4 +39,21 @@ actual class FastRegex actual constructor(actual val pattern: String) { transform(MatchGroups(it.groupValues)) } } + + actual fun transform( + input: String, + transformUnmatching: (String) -> T, + transformMatching: (MatchGroups) -> T, + ): List { + val matches = regex.findAll(input) + val parts = mutableListOf() + var next = 0 + matches.forEach { + parts.add(transformUnmatching(input.substring(next, it.range.first))) + parts.add(transformMatching(MatchGroups(it.groupValues))) + next = it.range.last + 1 + } + parts.add(transformUnmatching(input.substring(next))) + return parts + } } diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/ActionEvent.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ActionEvent.kt new file mode 100644 index 0000000..89f0925 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ActionEvent.kt @@ -0,0 +1,40 @@ +package com.zup.nimbus.core + +import com.zup.nimbus.core.dependency.CommonDependency +import com.zup.nimbus.core.tree.ServerDrivenAction +import com.zup.nimbus.core.tree.ServerDrivenEvent + +interface ActionEvent { + /** + * The action of this event. Use this object to find the name of the action, its properties and metadata. + */ + val action: ServerDrivenAction + /** + * The scope of the event that triggered this ActionEvent. + */ + val scope: ServerDrivenEvent +} + +/** + * All information needed for an action to execute. Represents the trigger event of an action. + */ +class ActionTriggeredEvent( + override val action: ServerDrivenAction, + override val scope: ServerDrivenEvent, + /** + * Every event can update the current state of the application based on the dependency graph. This set starts empty + * when a ServerDrivenEvent is run. A ServerDrivenEvent is what triggers ActionEvents. Use this to tell the + * ServerDrivenEvent (parent) which dependencies might need to propagate its changes to its dependents after it + * finishes running. "Might" because it will still check if the dependency has really changed since the last time its + * dependents were updated. + */ + val dependencies: MutableSet, +): ActionEvent + +/** + * All information needed for an action to initialize. Represents the initialization event of an action. + */ +class ActionInitializedEvent( + override val action: ServerDrivenAction, + override val scope: ServerDrivenEvent, +): ActionEvent diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/Nimbus.kt b/src/commonMain/kotlin/com/zup/nimbus/core/Nimbus.kt index 5aac8ff..5ae977d 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/Nimbus.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/Nimbus.kt @@ -1,98 +1,62 @@ package com.zup.nimbus.core -import com.zup.nimbus.core.action.getCoreActions -import com.zup.nimbus.core.action.getRenderHandlersForCoreActions -import com.zup.nimbus.core.component.getCoreComponents +import com.zup.nimbus.core.expression.parser.ExpressionParser +import com.zup.nimbus.core.ui.UILibraryManager import com.zup.nimbus.core.log.DefaultLogger import com.zup.nimbus.core.network.DefaultHttpClient import com.zup.nimbus.core.network.DefaultUrlBuilder import com.zup.nimbus.core.network.DefaultViewClient -import com.zup.nimbus.core.operations.getDefaultOperations -import com.zup.nimbus.core.render.ServerDrivenView +import com.zup.nimbus.core.scope.CommonScope import com.zup.nimbus.core.tree.DefaultIdManager -import com.zup.nimbus.core.tree.MalformedComponentError -import com.zup.nimbus.core.tree.MalformedJsonError -import com.zup.nimbus.core.tree.ObservableState -import com.zup.nimbus.core.tree.RenderNode - -class Nimbus(config: ServerDrivenConfig) { - // From config - val baseUrl = config.baseUrl - private val platform = config.platform - val actions = (getCoreActions() + (config.actions ?: emptyMap())).toMutableMap() - val actionObservers = config.actionObservers?.toMutableList() ?: ArrayList() - val operations = (getDefaultOperations() + (config.operations?.toMutableMap() ?: emptyMap())).toMutableMap() +import com.zup.nimbus.core.tree.dynamic.builder.EventBuilder +import com.zup.nimbus.core.tree.dynamic.builder.NodeBuilder +import com.zup.nimbus.core.ui.coreUILibrary + +/** + * The root scope of a nimbus application. Contains important objects like the logger and the httpClient. + */ +class Nimbus(config: ServerDrivenConfig): CommonScope( + parent = null, + states = (config.states ?: emptyList()) + ServerDrivenState("global", null), +) { + /** + * Manages UI elements like actions, components and operations. + */ + val uiLibraryManager = UILibraryManager(config.coreUILibrary ?: coreUILibrary, config.ui) + /** + * Logger of this instance of Nimbus. + */ val logger = config.logger ?: DefaultLogger() - val urlBuilder = config.urlBuilder ?: DefaultUrlBuilder(baseUrl) + /** + * Responsible for making every network interaction within this nimbus instance. + */ val httpClient = config.httpClient ?: DefaultHttpClient() + /** + * Responsible for retrieving Server Driven Screens from the backend. Uses the httpClient. + */ + val viewClient = config.viewClient?.let { it(this) } ?: DefaultViewClient(this) + /** + * Logic for building urls. Uses the baseUrl. + */ + val urlBuilder = config.urlBuilder?.let { it(config.baseUrl) } ?: DefaultUrlBuilder(config.baseUrl) + /** + * Logic for generating unique ids for components when one hasn't been defined in the json. + */ val idManager = config.idManager ?: DefaultIdManager() - val viewClient = config.viewClient ?: DefaultViewClient(httpClient, urlBuilder, idManager, logger, platform) - /** - * Core components. These don't correspond to real UI components and never reach the UI layer. These components are - * used to manipulate the structure of the UI tree and exists only in the core lib. - * - * The structural components are replaced by their result before being rendered by the UI layer. - * - * Examples: if (companions: then, else); switch (companions: case, default); foreach. + * The platform currently using the Nimbus. */ - internal val structuralComponents = getCoreComponents() - - // Other - val globalState = ObservableState("global", null) - + val platform = config.platform /** - * Functions to run once an action goes through the rendering process for the first time. - * This is currently used only for performing pre-fetches in navigation actions. + * A tool for parsing strings using the Nimbus Expression Language */ - internal val onActionRendered: Map = getRenderHandlersForCoreActions() - + val expressionParser = ExpressionParser(this) /** - * Creates a new ServerDrivenView that uses this Nimbus instance as its dependency manager. - * - * Check the documentation for ServerDrivenView for more details on the parameters. - * - * @param getNavigator a function that returns the ServerDrivenView's navigator. - * @param description a description for the new ServerDrivenView. - * @return the new ServerDrivenView. + * Builds a node tree from a json string or map. */ - fun createView(getNavigator: () -> ServerDrivenNavigator, description: String? = null): ServerDrivenView { - return ServerDrivenView(this, getNavigator, description) - } - + val nodeBuilder = NodeBuilder(this) /** - * Creates a RenderNode from a JSON string using the idManager provided in the config (or the default idManager if - * none has been provided. - * - * @param json the json string to deserialize into a RenderNode. - * @return the resulting RenderNode. - * @throws MalformedJsonError if the string is not a valid json. - * @throws MalformedComponentError if a component in the JSON is malformed. + * Builds an event from a json array. */ - @Throws(MalformedJsonError::class, MalformedComponentError::class) - fun createNodeFromJson(json: String): RenderNode { - return RenderNode.fromJsonString(json, idManager) - } - - private fun addAll(target: MutableMap, source: Map, entity: String) { - source.forEach { - if (target.containsKey(it.key)) { - logger.warn("$entity of name \"${it.key}\" already exists and is going to be replaced. Maybe you should " + - "consider another name.") - } - target[it.key] = it.value - } - } - - fun addActions(newActions: Map) { - addAll(actions, newActions, "Action") - } - - fun addActionObservers(observers: List) { - actionObservers.addAll(observers) - } - - fun addOperations(newOperations: Map) { - addAll(operations, newOperations, "Operation") - } + val eventBuilder = EventBuilder(this) } diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/ServerDrivenConfig.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ServerDrivenConfig.kt index 161c5ce..f9ba3a2 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/ServerDrivenConfig.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ServerDrivenConfig.kt @@ -1,15 +1,12 @@ package com.zup.nimbus.core +import com.zup.nimbus.core.ui.UILibrary import com.zup.nimbus.core.log.Logger import com.zup.nimbus.core.network.HttpClient import com.zup.nimbus.core.network.UrlBuilder import com.zup.nimbus.core.network.ViewClient -import com.zup.nimbus.core.render.ActionEvent import com.zup.nimbus.core.tree.IdManager -typealias ActionHandler = (event: ActionEvent) -> Unit -typealias OperationHandler = (arguments: List) -> Any? - data class ServerDrivenConfig( /** * The base url to use by this project when it encounters relative urls. @@ -20,34 +17,13 @@ data class ServerDrivenConfig( */ val platform: String, /** - * A map of ActionHandlers. Use this to provide customized actions to your server driven UIs. - * - * Each key in this map must be the action name with its namespace. Examples: "material:button", "layout:column". - * - * The value for each key `k` must the function to run once the action with name `k` is triggered. This function - * receives the ActionEvent and must return nothing. The ActionEvent has all the data needed to run the action. - * - * You can also add an action handler to an instance of `Nimbus` via the method `addActions` - */ - val actions: Map? = null, - /** - * A list of action handlers to run when any action is triggered. This runs after the handler in `action` and is - * used to implement a behavior that should run for every action, no matter its name. - * - * This can be useful for implementing Analytics. + * The custom UI extensions to use within this nimbus instance. */ - val actionObservers: List? = null, + val ui: List? = null, /** - * A map of OperationHandlers. Use this to provide customized operations to your server driven UIs. - * - * Each key in this map must be the operation name. Examples: "isDocumentValid", "formatPhoneNumber". - * - * The value for each key `k` must the function to run once the operation with name `k` is called. This function - * receives a list with the operation parameters and must return its result. - * - * You can also add an operation handler to an instance of `Nimbus` via the method `addOperations` + * Use this to replace the core UI library. */ - val operations: Map? = null, + val coreUILibrary: UILibrary? = null, /** * The logger to call when printing errors, warning and information messages. By default, Nimbus will use its * DefaultLogger that just prints the messages to the console. @@ -57,7 +33,7 @@ data class ServerDrivenConfig( * A logic to create full URLs from relative paths. By default, Nimbus checks if the path starts with "/". If it does, * the full URL is the baseUrl provided in this config concatenated with the path. Otherwise, it's the path itself. */ - val urlBuilder: UrlBuilder? = null, + val urlBuilder: ((baseUrl: String) -> UrlBuilder)? = null, /** * The lowest level API for making requests in Nimbus. By default, Nimbus will use its DefaultHttpClient, which just * calls kotlin's ktor lib with the provided requests. @@ -74,10 +50,15 @@ data class ServerDrivenConfig( * A ViewClient is also responsible for implementing the prefetch logic, indicated by the property "prefetch" of a * navigation action. Check the DefaultViewClient documentation to know more about how it deals with prefetching. */ - val viewClient: ViewClient? = null, + val viewClient: ((nimbus: Nimbus) -> ViewClient)? = null, /** * An id generator for creating unique ids for nodes in a UI tree when one is not provided by the JSON. By default, * the ids are incremental, starting at 0 and prefixed with "nimbus:". */ val idManager: IdManager? = null, + /** + * The states to be globally available in this Nimbus instance. By default, only the state named "global" exists in + * the nimbus (global) scope. + */ + val states: List? = null, ) diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/ServerDrivenState.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ServerDrivenState.kt new file mode 100644 index 0000000..bebf919 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ServerDrivenState.kt @@ -0,0 +1,87 @@ +package com.zup.nimbus.core + +import com.zup.nimbus.core.dependency.CommonDependency +import com.zup.nimbus.core.dependency.DependencyUpdateManager +import com.zup.nimbus.core.utils.deepCopyMutable +import com.zup.nimbus.core.utils.setMapValue +import com.zup.nimbus.core.utils.valueOfPath + +/** + * Represents a state in the scope hierarchy. + * + * Considering the Json tree, a state is represented by the key "state" in a component. + */ +class ServerDrivenState( + /** + * The id of the state. + */ + val id: String, + /** + * The value of the state. + * If you need set this value and have this change propagated, consider using "set" instead. + */ + internal var value: Any?, +): CommonDependency() { + /** + * Gets the current value of this state. Do not use this value as settable. + * + * @see set, to set the new value of this state use the `set` function. + * @return the current value of this state. + */ + fun get(): Any? { + return value + } + + /** + * If the value is a list or map, but is not mutable, returns a mutable version of it. + * Otherwise, returns the input. + */ + private fun getMutable(maybeImmutable: Any?): Any? { + if (maybeImmutable is Map<*, *> && maybeImmutable !is MutableMap<*, *>) return maybeImmutable.toMutableMap() + if (maybeImmutable is List<*> && maybeImmutable !is MutableList<*>) return maybeImmutable.toMutableList() + return maybeImmutable + } + + private fun setValueAtPath(newValue: Any?, path: String) { + if (path.isEmpty()) { + value = newValue + } else { + if (value !is MutableMap<*, *>) value = HashMap() + @Suppress("UNCHECKED_CAST") + setMapValue(value as MutableMap, path, newValue) + } + } + + /** + * Changes the value of this state at the provided path. + * + * @param newValue the new value of "state.value.$path". Must be encodable, i.e. null, string, number, boolean, + * Map or List. + * @param path the path within the state to modify. Example: "" to alter the entire state. "foo.bar" to alter the + * property "bar" of "foo" in the map "state.value". If "path" is not empty and "state.value" is not a mutable map, it + * is converted to one. The path must only contain letters, numbers and underscores separated by dots. + * @param shouldUpdateDependents whether or not to propagate this change to everything that depends on the value of + * this state. This is useful for making multiple changes to states and still have a single UI update. In this case, + * you would pass false to every `set`, but the last one. By default, it will update its dependents. + */ + fun set(newValue: Any?, path: String, shouldUpdateDependents: Boolean = true) { + val currentValue: Any? = valueOfPath(value, path) + if (currentValue != newValue) { + val mutableValue = getMutable(newValue) + setValueAtPath(mutableValue, path) + hasChanged = true + if (shouldUpdateDependents) DependencyUpdateManager.updateDependentsOf(this) + } + } + + /** + * Shortcut to `set(newValue, "")`, i.e. replaces the entire value of the state with `newValue`. + */ + fun set(newValue: Any?) { + set(newValue, "") + } + + fun clone(): ServerDrivenState { + return ServerDrivenState(id, deepCopyMutable(value)) + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/ServerDrivenView.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ServerDrivenView.kt new file mode 100644 index 0000000..51f24f6 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ServerDrivenView.kt @@ -0,0 +1,34 @@ +package com.zup.nimbus.core + +import com.zup.nimbus.core.scope.CommonScope + +/** + * A scope for the current view in a navigator. + */ +class ServerDrivenView( + /** + * The parent nimbus scope. + */ + val nimbus: Nimbus, + /** + * The states in this scope. Useful for creating view parameters in the navigation. + */ + states: List? = null, + /** + * A description for this view. Suggestion: the URL used to load the content of this view or "json", if a local json + * string was used to load it. + */ + val description: String? = null, + /** + * A function to get the navigator that spawned this view. + * + * Attention: this is a function so we can prevent a cyclical reference between Kotlin Native and Swift. Replacing + * this with a direct reference will cause memory leaks. + */ + getNavigator: () -> ServerDrivenNavigator, +): CommonScope(parent = nimbus, states = states) { + constructor(nimbus: Nimbus, getNavigator: () -> ServerDrivenNavigator): + this(nimbus, null, null, getNavigator) + + val navigator = getNavigator() +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/action/condition.kt b/src/commonMain/kotlin/com/zup/nimbus/core/action/condition.kt deleted file mode 100644 index 230ecdd..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/action/condition.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.zup.nimbus.core.action - -import com.zup.nimbus.core.render.ActionEvent -import com.zup.nimbus.core.utils.UnexpectedDataTypeError -import com.zup.nimbus.core.utils.valueOfKey - -internal fun condition(event: ActionEvent) { - val logger = event.view.nimbusInstance.logger - val properties = event.action.properties - try { - val condition: Boolean = valueOfKey(properties, "condition") - val onTrue: ((_: Any?) -> Unit)? = valueOfKey(properties, "onTrue") - val onFalse: ((_: Any?) -> Unit)? = valueOfKey(properties, "onFalse") - if (condition && onTrue != null) onTrue(null) - else if (!condition && onFalse != null) onFalse(null) - } catch (e: UnexpectedDataTypeError) { - logger.error("Error while executing conditional action.\n${e.message}") - } -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/action/index.kt b/src/commonMain/kotlin/com/zup/nimbus/core/action/index.kt deleted file mode 100644 index bca3a93..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/action/index.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.zup.nimbus.core.action - -import com.zup.nimbus.core.ActionHandler - -internal fun getCoreActions(): Map { - return mapOf( - "push" to { push(it) }, - "pop" to { pop(it) }, - "popTo" to { popTo(it) }, - "present" to { present(it) }, - "dismiss" to { dismiss(it) }, - "log" to { log(it) }, - "sendRequest" to { sendRequest(it) }, - "setState" to { setState(it) }, - "condition" to { condition(it) }, - "setContent" to { setContent(it) }, - ) -} - - -internal fun getRenderHandlersForCoreActions(): Map { - return mapOf( - "push" to { onPushOrPresentRendered(it) }, - "present" to { onPushOrPresentRendered(it) }, - ) -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/action/navigation.kt b/src/commonMain/kotlin/com/zup/nimbus/core/action/navigation.kt deleted file mode 100644 index 642b80c..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/action/navigation.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.zup.nimbus.core.action - -import com.zup.nimbus.core.log.Logger -import com.zup.nimbus.core.network.ServerDrivenHttpMethod -import com.zup.nimbus.core.network.ViewRequest -import com.zup.nimbus.core.render.ActionEvent -import com.zup.nimbus.core.tree.IdManager -import com.zup.nimbus.core.tree.MalformedComponentError -import com.zup.nimbus.core.tree.RenderNode -import com.zup.nimbus.core.utils.UnexpectedDataTypeError -import com.zup.nimbus.core.utils.valueOfEnum -import com.zup.nimbus.core.utils.valueOfKey - -private fun getFallback(actionProperties: Map?, idManager: IdManager, logger: Logger): RenderNode? { - val fallback: Map = valueOfKey(actionProperties, "fallback") ?: return null - return try { - RenderNode.fromMap(fallback, idManager) - } catch (e: MalformedComponentError) { - logger.error("The component provided to \"fallback\" is Malformed.\n${e.message}") - null - } -} - -private fun requestFromEvent(event: ActionEvent): ViewRequest { - val logger = event.view.nimbusInstance.logger - val properties = event.action.properties - return ViewRequest( - url = valueOfKey(properties, "url"), - method = valueOfEnum(properties, "method", ServerDrivenHttpMethod.Get), - headers = valueOfKey(properties, "headers"), - fallback = getFallback(properties, event.view.nimbusInstance.idManager, logger), - ) -} - -private fun pushOrPresent(event: ActionEvent, isPush: Boolean) { - val logger = event.view.nimbusInstance.logger - try { - val request = requestFromEvent(event) - if (isPush) event.view.getNavigator().push(request) - else event.view.getNavigator().present(request) - } catch (e: UnexpectedDataTypeError) { - logger.error("Error while navigating.\n${e.message}") - } -} - -internal fun push(event: ActionEvent) = pushOrPresent(event, true) - -internal fun pop(event: ActionEvent) = event.view.getNavigator().pop() - -internal fun popTo(event: ActionEvent) { - val logger = event.view.nimbusInstance.logger - try { - event.view.getNavigator().popTo(valueOfKey(event.action.properties, "url")) - } catch (e: UnexpectedDataTypeError) { - logger.error("Error while navigating.\n${e.message}") - } -} - -internal fun present(event: ActionEvent) = pushOrPresent(event, false) - -internal fun dismiss(event: ActionEvent) = event.view.getNavigator().dismiss() - -internal fun onPushOrPresentRendered(event: ActionEvent) { - try { - val prefetch: Boolean = valueOfKey(event.action.properties, "prefetch") ?: false - if (!prefetch) return - val request = requestFromEvent(event) - event.view.nimbusInstance.viewClient.preFetch(request) - } catch (e: Throwable) { - event.view.nimbusInstance.logger.error("Error while pre-fetching view.\n${e.message}") - } -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/action/setContent.kt b/src/commonMain/kotlin/com/zup/nimbus/core/action/setContent.kt deleted file mode 100644 index c770be7..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/action/setContent.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.zup.nimbus.core.action - -import com.zup.nimbus.core.render.ActionEvent -import com.zup.nimbus.core.render.UnexpectedRootStructuralComponent -import com.zup.nimbus.core.tree.MalformedComponentError -import com.zup.nimbus.core.tree.RenderNode -import com.zup.nimbus.core.tree.TreeUpdateMode -import com.zup.nimbus.core.utils.UnexpectedDataTypeError -import com.zup.nimbus.core.utils.valueOfEnum -import com.zup.nimbus.core.utils.valueOfKey - -internal fun setContent(event: ActionEvent) { - val logger = event.view.nimbusInstance.logger - val properties = event.action.properties - try { - val id: String = valueOfKey(properties, "id") - val value: Map = valueOfKey(properties, "value") - val mode: TreeUpdateMode = valueOfEnum(properties, "mode", TreeUpdateMode.Append) - val valueAsTree = RenderNode.fromMap(value, event.view.nimbusInstance.idManager) - event.view.renderer.paint(valueAsTree, id, mode) - } catch (e: UnexpectedDataTypeError) { - logger.error("Error while executing the action \"setContent\".\n${e.message}") - } catch (e: MalformedComponentError) { - logger.error("Could not set content because the provided value is not a valid Server Driven Node.\n${e.message}") - } catch (e: UnexpectedRootStructuralComponent) { - logger.error("Could not set content because the provided element is a structural component. Please wrap it " + - "under another component.\n${e.message}") - } -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/action/setState.kt b/src/commonMain/kotlin/com/zup/nimbus/core/action/setState.kt deleted file mode 100644 index 39ffaeb..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/action/setState.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.zup.nimbus.core.action - -import com.zup.nimbus.core.render.ActionEvent -import com.zup.nimbus.core.utils.UnexpectedDataTypeError -import com.zup.nimbus.core.utils.valueOfKey - -internal fun setState(event: ActionEvent) { - val properties = event.action.properties - try { - val path: String = valueOfKey(properties, "path") - val value: Any? = valueOfKey(properties, "value") - event.view.renderer.setState(event.node, path, value) - } catch (e: UnexpectedDataTypeError) { - event.view.nimbusInstance.logger.error("Error while setting state.\n${e.message}") - } -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/component/errors.kt b/src/commonMain/kotlin/com/zup/nimbus/core/component/errors.kt deleted file mode 100644 index 012efe5..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/component/errors.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.zup.nimbus.core.component - -open class ComponentStructureError(message: String): IllegalArgumentException(message) - -class UnexpectedComponentError(message: String): ComponentStructureError(message) - -class MissingComponentError(message: String): ComponentStructureError(message) - diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/component/forEach.kt b/src/commonMain/kotlin/com/zup/nimbus/core/component/forEach.kt deleted file mode 100644 index b04825d..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/component/forEach.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.zup.nimbus.core.component - -import com.zup.nimbus.core.tree.RenderNode -import com.zup.nimbus.core.utils.deepCopy -import com.zup.nimbus.core.utils.valueOfKey - -// todo: the foreach computes every node again every time a state in the hierarchy changes. This might cause each node -// state to be reinitialized and also may affect performance. This first implementation won't care about this, but we -// should look into it before releasing a stable version (1.0.0). - -private fun getIterationKey(item: Any?, key: String?, index: Int): String { - val keyValue: Any? = if (key == null) null else valueOfKey(item, key) - return if (keyValue == null) "$index" else "$keyValue" -} - -private fun deepCopyChildren(children: List?, iterationKey: String): List? { - return children?.map { - RenderNode( - id = "${it.id}:$iterationKey", - component = it.component, - rawProperties = deepCopy(it.rawProperties), - rawChildren = deepCopyChildren(it.rawChildren, iterationKey), - stateId = it.state?.id, - stateValue = it.state?.value, - implicitStates = null, - children = null, - properties = null, - stateHierarchy = null, - ) - } -} - -internal fun forEachComponent(node: RenderNode): List { - val items: List = valueOfKey(node.properties, "items") ?: emptyList() - val iteratorName: String = valueOfKey(node.properties, "iteratorName") ?: "item" - val indexName: String = valueOfKey(node.properties, "indexName") ?: "index" - val key: String? = valueOfKey(node.properties, "key") - val result = ArrayList() - items.forEachIndexed { index, item -> - node.rawChildren?.forEach { templateChild -> - val iterationKey = getIterationKey(item, key, index) - result.add(RenderNode( - id = "${templateChild.id}:$iterationKey", - component = templateChild.component, - rawProperties = deepCopy(templateChild.rawProperties), - rawChildren = deepCopyChildren(templateChild.rawChildren, iterationKey), - stateId = templateChild.state?.id, - stateValue = templateChild.state?.value, - implicitStates = mapOf( - iteratorName to item, - indexName to index, - ), - children = null, - properties = null, - stateHierarchy = null, - )) - } - } - return result -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/component/if.kt b/src/commonMain/kotlin/com/zup/nimbus/core/component/if.kt deleted file mode 100644 index 0cc850a..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/component/if.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.zup.nimbus.core.component - -import com.zup.nimbus.core.tree.RenderNode -import com.zup.nimbus.core.utils.valueOfKey - -internal fun ifComponent(node: RenderNode): List { - val condition: Boolean = valueOfKey(node.properties, "condition") - var thenNode: RenderNode? = null - var elseNode: RenderNode? = null - node.rawChildren?.forEach { - when (it.component) { - "then" -> thenNode = it - "else" -> elseNode = it - else -> throw UnexpectedComponentError("Component \"if\" should only have components \"then\" and \"else\" as " + - "children. Found: \"${it.component}\".") - } - } - if (thenNode == null) throw MissingComponentError("Component \"if\" must have a component \"then\" as child.") - return (if (condition) thenNode?.rawChildren else elseNode?.rawChildren) ?: emptyList() -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/component/index.kt b/src/commonMain/kotlin/com/zup/nimbus/core/component/index.kt deleted file mode 100644 index a4e1885..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/component/index.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zup.nimbus.core.component - -import com.zup.nimbus.core.tree.RenderNode - -fun getCoreComponents(): Map List> { - return mapOf( - "forEach" to { forEachComponent(it) }, - "if" to { ifComponent(it) }, - "switch" to { switchComponent(it) }, - ) -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/component/switch.kt b/src/commonMain/kotlin/com/zup/nimbus/core/component/switch.kt deleted file mode 100644 index 3593f53..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/component/switch.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.zup.nimbus.core.component - -import com.zup.nimbus.core.tree.RenderNode - -/* -interface Switch { - value?: any, - children: (Case | Default)[], -} - -interface Case { - is: boolean, - children: Component[], -} - -interface Default { - children: Component[], -} -*/ - -internal fun switchComponent(node: RenderNode): List { - // todo - print(node) - return emptyList() -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/dependency/CommonDependency.kt b/src/commonMain/kotlin/com/zup/nimbus/core/dependency/CommonDependency.kt new file mode 100644 index 0000000..5aac8f2 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/dependency/CommonDependency.kt @@ -0,0 +1,16 @@ +package com.zup.nimbus.core.dependency + + +abstract class CommonDependency: Dependency { + override val dependents = mutableSetOf() + + override var hasChanged = false + + override fun addDependent(dependent: Dependent) { + dependents.add(dependent) + } + + override fun removeDependent(dependent: Dependent) { + dependents.remove(dependent) + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/dependency/Dependency.kt b/src/commonMain/kotlin/com/zup/nimbus/core/dependency/Dependency.kt new file mode 100644 index 0000000..d389d8c --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/dependency/Dependency.kt @@ -0,0 +1,27 @@ +package com.zup.nimbus.core.dependency + +/** + * Makes this a node in the dependency graph so it can be a dependency of another node. + * + * When this changes and it's time for its dependents to update, the `update()` method of each + * dependent will be called. + */ +interface Dependency { + /** + * The list of nodes that depend on this in the dependency graph. + */ + val dependents: MutableSet + /** + * Whether or not this dependency changed since the last time its dependents were updated. + * This must be set to false by whatever updates the dependents. + */ + var hasChanged: Boolean + /** + * Makes `dependent` depend on this. + */ + fun addDependent(dependent: Dependent) + /** + * Removes a dependent. + */ + fun removeDependent(dependent: Dependent) +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/dependency/DependencyUpdateManager.kt b/src/commonMain/kotlin/com/zup/nimbus/core/dependency/DependencyUpdateManager.kt new file mode 100644 index 0000000..0a4988a --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/dependency/DependencyUpdateManager.kt @@ -0,0 +1,73 @@ +package com.zup.nimbus.core.dependency + +/** + * Manages a dependency graph by updating the nodes that need to be updated. + */ +object DependencyUpdateManager { + private fun traverseAndUpdateLevels( + dependency: Dependency, + levels: MutableMap, + dependencyMap: MutableMap>, + currentLevel: Int, + ) { + dependency.dependents.forEach { + val level = levels[it] + if (level == null || level < currentLevel) { + levels[it] = currentLevel + dependencyMap[it] = dependencyMap[it] ?: mutableSetOf() + dependencyMap[it]?.add(dependency) + } + if (it is Dependency) traverseAndUpdateLevels(it, levels, dependencyMap, currentLevel + 1) + } + } + + private fun createGroupsAndDependencyMap( + dependencies: Set, + ): Pair>, Map>> { + val levels = mutableMapOf() + val dependencyMap = mutableMapOf>() + dependencies.forEach { traverseAndUpdateLevels(it, levels, dependencyMap, 0) } + val groups = mutableListOf>() + levels.forEach { + val dependent = it.key + val level = it.value + if (groups.getOrNull(level) == null) groups.add(level, mutableListOf()) + groups[level].add(dependent) + } + return Pair(groups, dependencyMap) + } + + @Suppress("NestedBlockDepth") + private fun updateDependents( + groups: List>, + dependencyMap: Map>, + ): Set { + val updated = mutableSetOf() + groups.forEach { group -> + group.forEach { dependent -> + if (dependencyMap[dependent]?.find { it.hasChanged } != null) { + dependent.update() + dependencyMap[dependent]?.let { updated.addAll(it) } + } + } + } + return updated + } + + /** + * Propagates an update in the dependency graph. + * + * This algorithm takes a list of dependencies that should have its dependents updated and update them. If a dependent + * changes and it is a also a dependency, the update is propagated recursively. + */ + fun updateDependentsOf(dependencies: Set) { + val (groups, dependencyMap) = createGroupsAndDependencyMap(dependencies) + val updated = updateDependents(groups, dependencyMap) + updated.forEach { it.hasChanged = false } + } + + /** + * Alias for updateDependentsOf(listOf(dependency)) + */ + fun updateDependentsOf(dependency: Dependency) = updateDependentsOf(setOf(dependency)) +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/dependency/Dependent.kt b/src/commonMain/kotlin/com/zup/nimbus/core/dependency/Dependent.kt new file mode 100644 index 0000000..4996e57 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/dependency/Dependent.kt @@ -0,0 +1,14 @@ +package com.zup.nimbus.core.dependency + +/** + * Makes this a node in the dependency graph so it can depend on another node. + * + * When a dependency changes and it's time to update this, the method `update()` + * will be called. + */ +interface Dependent { + /** + * Updates this node according to its dependencies. + */ + fun update() +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/expression/Expression.kt b/src/commonMain/kotlin/com/zup/nimbus/core/expression/Expression.kt new file mode 100644 index 0000000..8c048f9 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/expression/Expression.kt @@ -0,0 +1,8 @@ +package com.zup.nimbus.core.expression + +/** + * The compiled version of an expression string, i.e., the Abstract Syntax Tree (AST). + */ +interface Expression { + fun getValue(): Any? +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/expression/Literal.kt b/src/commonMain/kotlin/com/zup/nimbus/core/expression/Literal.kt new file mode 100644 index 0000000..dfe36ae --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/expression/Literal.kt @@ -0,0 +1,7 @@ +package com.zup.nimbus.core.expression + +class Literal(private val value: Any?): Expression { + override fun getValue(): Any? { + return value + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/expression/Operation.kt b/src/commonMain/kotlin/com/zup/nimbus/core/expression/Operation.kt new file mode 100644 index 0000000..a45690d --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/expression/Operation.kt @@ -0,0 +1,47 @@ +package com.zup.nimbus.core.expression + +import com.zup.nimbus.core.OperationHandler +import com.zup.nimbus.core.dependency.CommonDependency +import com.zup.nimbus.core.dependency.Dependent +import com.zup.nimbus.core.scope.CloneAfterInitializationError +import com.zup.nimbus.core.scope.DoubleInitializationError +import com.zup.nimbus.core.scope.LazilyScoped +import com.zup.nimbus.core.scope.Scope + +class Operation( + private val handler: OperationHandler, + private val arguments: List, +): Expression, CommonDependency(), Dependent, LazilyScoped { + private var value: Any? = null + private var hasInitialized = false + + override fun initialize(scope: Scope) { + if (hasInitialized) throw DoubleInitializationError() + arguments.forEach { + if (it is LazilyScoped<*>) it.initialize(scope) + if (it is CommonDependency) it.addDependent(this) + } + hasInitialized = true + update() + hasChanged = false + } + + override fun update() { + val argValues = arguments.map { it.getValue() } + val newValue = handler(argValues) + if (value != newValue) { + value = newValue + hasChanged = true + } + } + + override fun getValue(): Any? { + return value + } + + override fun clone(): Operation { + if (hasInitialized) throw CloneAfterInitializationError() + val clonedArguments = arguments.map { if (it is LazilyScoped<*>) it.clone() as Expression else it } + return Operation(handler, clonedArguments) + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/expression/StateReference.kt b/src/commonMain/kotlin/com/zup/nimbus/core/expression/StateReference.kt new file mode 100644 index 0000000..eab3b13 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/expression/StateReference.kt @@ -0,0 +1,47 @@ +package com.zup.nimbus.core.expression + +import com.zup.nimbus.core.ServerDrivenState +import com.zup.nimbus.core.dependency.CommonDependency +import com.zup.nimbus.core.dependency.Dependent +import com.zup.nimbus.core.scope.CloneAfterInitializationError +import com.zup.nimbus.core.scope.DoubleInitializationError +import com.zup.nimbus.core.scope.LazilyScoped +import com.zup.nimbus.core.scope.Scope +import com.zup.nimbus.core.scope.closestState +import com.zup.nimbus.core.utils.valueOfPath + +class StateReference( + private var id: String, + private val path: String, + private var onNotFound: ((String, Scope) -> Unit)? = null, +): Expression, CommonDependency(), Dependent, LazilyScoped { + private var state: ServerDrivenState? = null + private var value: Any? = null + + override fun initialize(scope: Scope) { + if (state != null) throw DoubleInitializationError() + state = scope.closestState(id) + if (state == null) onNotFound?.let { it(id, scope) } + update() + hasChanged = false + state?.addDependent(this) + onNotFound = null + } + + override fun getValue(): Any? { + return value + } + + override fun update() { + val newValue: Any? = valueOfPath(state?.value, path) + if (value != newValue) { + value = newValue + hasChanged = true + } + } + + override fun clone(): StateReference { + if (state != null) throw CloneAfterInitializationError() + return StateReference(id, path, onNotFound) + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/expression/StringTemplate.kt b/src/commonMain/kotlin/com/zup/nimbus/core/expression/StringTemplate.kt new file mode 100644 index 0000000..f0392ba --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/expression/StringTemplate.kt @@ -0,0 +1,44 @@ +package com.zup.nimbus.core.expression + +import com.zup.nimbus.core.dependency.CommonDependency +import com.zup.nimbus.core.dependency.Dependent +import com.zup.nimbus.core.scope.CloneAfterInitializationError +import com.zup.nimbus.core.scope.DoubleInitializationError +import com.zup.nimbus.core.scope.LazilyScoped +import com.zup.nimbus.core.scope.Scope + +class StringTemplate( + private val composition: List, +): Expression, CommonDependency(), Dependent, LazilyScoped { + private var value: String = "" + private var hasInitialized = false + + override fun initialize(scope: Scope) { + if (hasInitialized) throw DoubleInitializationError() + composition.forEach { + if (it is LazilyScoped<*>) it.initialize(scope) + if (it is CommonDependency) it.addDependent(this) + } + hasInitialized = true + update() + hasChanged = false + } + + override fun getValue(): String { + return value + } + + override fun update() { + val newValue = composition.joinToString("") { "${it.getValue() ?: ""}" } + if (value != newValue) { + value = newValue + hasChanged = true + } + } + + override fun clone(): StringTemplate { + if (hasInitialized) throw CloneAfterInitializationError() + val clonedComposition = composition.map { if (it is LazilyScoped<*>) it.clone() as Expression else it } + return StringTemplate(clonedComposition) + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/ExpressionParser.kt b/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/ExpressionParser.kt new file mode 100644 index 0000000..9555012 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/ExpressionParser.kt @@ -0,0 +1,50 @@ +package com.zup.nimbus.core.expression.parser + +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.expression.Expression +import com.zup.nimbus.core.regex.toFastRegex + +//Do not remove the Redundant character escape '\}' in RegExp, this causes error when using android regex implementation +private val expressionRegex = """(\\*)@\{(([^'}]|('([^'\\]|\\.)*'))*)\}""".toFastRegex() +private val fullMatchExpressionRegex = """^@\{(([^'}]|('([^'\\]|\\.)*'))*)\}$""".toFastRegex() + +/** + * Parser for Server driven expressions. + * + * A string must be converted to an expression if it contains the pattern `@{.*}`. + * + * Considering the string contains the aforementioned pattern, any `@{` can be escaped with `\`. When an expression + * is escaped, the parser will return the StringTemplate with the Literal corresponding to the escaped string. Example: + * `\@{myState}` will be parsed as `StringTemplate(listOf(Literal("@{myState}")))`. + */ +class ExpressionParser(nimbus: Nimbus) { + private val stateReferenceParser = StateReferenceParser(nimbus) + private val operationParser = OperationParser(nimbus) + private val stringTemplateParser = StringTemplateParser(nimbus) + + fun parseExpression(code: String): Expression { + // if it's a Literal + val literal = LiteralParser.parse(code) + if (literal != null) return literal + + // if it's an Operation + val isOperation = code.contains("(") + if (isOperation) return operationParser.parse(code) + + // otherwise, it's a state reference + return stateReferenceParser.parse(code) + } + + fun containsExpression(string: String): Boolean { + return expressionRegex.containsMatchIn(string) + } + + fun parseString(stringContainingExpression: String): Expression { + val fullMatch = fullMatchExpressionRegex.findWithGroups(stringContainingExpression) + if (fullMatch != null) { + val (code) = fullMatch.destructured + return parseExpression(code) + } + return stringTemplateParser.parse(stringContainingExpression) + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/LiteralParser.kt b/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/LiteralParser.kt new file mode 100644 index 0000000..c4cf571 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/LiteralParser.kt @@ -0,0 +1,28 @@ +package com.zup.nimbus.core.expression.parser + +import com.zup.nimbus.core.expression.Literal +import com.zup.nimbus.core.regex.matches +import com.zup.nimbus.core.regex.toFastRegex + +private val literalRegex = """^\d+((.)|(.\d+)?)$""".toFastRegex() + +object LiteralParser { + fun parse(code: String): Literal? { + when (code) { + "true" -> return Literal(true) + "false" -> return Literal(false) + "null" -> return Literal(null) + } + + if (code.matches(literalRegex)) { + if (code.contains(".")) return Literal(code.toDouble()) + return Literal(code.toInt()) + } + + if (code.startsWith("'") && code.endsWith("'")) { + return Literal(code.drop(1).dropLast(1).replace("""\'""", "'")) + } + + return null + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/OperationParser.kt b/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/OperationParser.kt new file mode 100644 index 0000000..a83ed4f --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/OperationParser.kt @@ -0,0 +1,76 @@ +package com.zup.nimbus.core.expression.parser + +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.expression.Expression +import com.zup.nimbus.core.expression.Literal +import com.zup.nimbus.core.expression.Operation +import com.zup.nimbus.core.regex.toFastRegex + +private val operationRegex = """^(\w+)\((.*)\)$""".toFastRegex() + +private val dpaTransitions: Map> = mapOf( + "initial" to listOf( + Transition(""",|$""", true, null, null, "final"), // end of parameter + Transition("(", "(", null, "insideParameterList"), // start of a parameter list + Transition("""'([^']|(\\.))*'""", true, null, null, "initial"), // strings + Transition("""[^)]""", true, null, null, "initial"), // general symbols + ), + "insideParameterList" to listOf( + Transition("(", "(", null, "insideParameterList"), // start of another parameter list + // end of a parameter list, check if still inside a parameter list + Transition(")", null, "(", "isParameterListOver"), + Transition("""'([^']|(\\.))*'""", true, null, null, "insideParameterList"), // strings + Transition(".", true, null, null, "insideParameterList"), // general symbols + ), + "isParameterListOver" to listOf( + Transition(null, DPA.Symbols.EMPTY, "initial"), // end of parameter list, go back to initial state + // still inside a parameter list, go back to state "insideParameterList" + Transition(null, null, "insideParameterList"), + ), +) + +private val parameterMatcher = DPA("initial", "final", dpaTransitions) + +private fun parseParameters(parameterString: String): List { + val parameters = mutableListOf() + var position = 0 + + while (position < parameterString.length) { + val match = parameterMatcher.match(parameterString.substring(position)) + ?: throw IllegalArgumentException("wrong format for parameters: $parameterString") + parameters.add(match.removeSuffix(",").trim()) + position += match.length + } + + return parameters +} + +class OperationParser(private val nimbus: Nimbus) { + @Suppress("ReturnCount") + fun parse(code: String): Expression { + val match = operationRegex.findWithGroups(code) + + if (match == null) { + nimbus.logger.error("Invalid operation in expression: $code. Using null as its value.") + return Literal(null) + } + + val (operationName, paramString) = match.destructured + val operationHandler = nimbus.uiLibraryManager.getOperation(operationName) + if (operationHandler == null) { + nimbus.logger.error("Operation with name \"$operationName\" doesn't exist. Using null as its value.") + return Literal(null) + } + + return try { + val params = parseParameters(paramString) + val resolvedParams = params.map { param -> + nimbus.expressionParser.parseExpression(param) + } + Operation(operationHandler, resolvedParams) + } catch (e: IllegalArgumentException) { + nimbus.logger.error(e.message ?: "Error while parsing expression.") + Literal(null) + } + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/StateReferenceParser.kt b/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/StateReferenceParser.kt new file mode 100644 index 0000000..6f3d32d --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/StateReferenceParser.kt @@ -0,0 +1,43 @@ +package com.zup.nimbus.core.expression.parser + +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.expression.Expression +import com.zup.nimbus.core.expression.Literal +import com.zup.nimbus.core.expression.StateReference +import com.zup.nimbus.core.regex.matches +import com.zup.nimbus.core.regex.toFastRegex +import com.zup.nimbus.core.scope.Scope +import com.zup.nimbus.core.scope.getPathToScope + +private val stateReferenceRegex = """^[\w\d_]+(\[\d+\])*(\.([\w\d_]+(\[\d+\])*))*$""".toFastRegex() +private val pathRegex = """^([^\.\[\]]+)\.?(.*)""".toFastRegex() +private val keyWords = setOf("true", "false", "null") + +class StateReferenceParser(private val nimbus: Nimbus) { + private fun pathError(path: String): Literal { + nimbus.logger.error("invalid path \"$path\". Please, make sure your variable names contain only letters, " + + "numbers and the symbol \"_\". To access substructures use \".\" and to access array indexes use " + + "\"[index]\". Using null in the place of this expression.") + return Literal(null) + } + + private fun stateIdError(id: String): Literal { + nimbus.logger.error( + "The referred state is invalid because it uses a key word as its id: $id. Using null in its place." + ) + return Literal(null) + } + + private val stateNotFoundError: (String, Scope) -> Unit = { stateId, scope -> + val location = "At: ${scope.getPathToScope()}" + nimbus.logger.error("Couldn't find state with id \"$stateId\". Using null in its place.\n$location") + } + + fun parse(path: String): Expression { + if (!path.matches(stateReferenceRegex)) return pathError(path) + val pathMatch = pathRegex.findWithGroups(path) ?: return pathError(path) + val (stateId, statePath) = pathMatch.destructured + if (keyWords.contains(stateId)) return stateIdError(stateId) + return StateReference(stateId, statePath, stateNotFoundError) + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/StringTemplateParser.kt b/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/StringTemplateParser.kt new file mode 100644 index 0000000..e52e9f1 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/StringTemplateParser.kt @@ -0,0 +1,27 @@ +package com.zup.nimbus.core.expression.parser + +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.expression.Literal +import com.zup.nimbus.core.expression.StringTemplate +import com.zup.nimbus.core.regex.toFastRegex + +private val expressionRegex = """(\\*)@\{(([^'}]|('([^'\\]|\\.)*'))*)\}""".toFastRegex() + +class StringTemplateParser(private val nimbus: Nimbus) { + fun parse(stringContainingExpression: String): StringTemplate { + val composition = expressionRegex.transform(stringContainingExpression, { Literal(it) }) { + val (slashes, code) = it.destructured + val isExpressionEscaped = slashes.length % 2 == 1 + val escapedSlashes = slashes.replace("""\\""", """\""") + + if (isExpressionEscaped) return@transform Literal("${escapedSlashes.dropLast(1)}@{$code}") + + val expression = nimbus.expressionParser.parseExpression(code) + return@transform ( + if (escapedSlashes.isEmpty()) expression + else StringTemplate(listOf(Literal(escapedSlashes), expression)) + ) + } + return StringTemplate(composition) + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/render/automaton.kt b/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/automaton.kt similarity index 96% rename from src/commonMain/kotlin/com/zup/nimbus/core/render/automaton.kt rename to src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/automaton.kt index 12ee1ea..709d248 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/render/automaton.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/expression/parser/automaton.kt @@ -1,5 +1,5 @@ @file:Suppress("ComplexCondition") // todo: verify -package com.zup.nimbus.core.render +package com.zup.nimbus.core.expression.parser import com.zup.nimbus.core.regex.FastRegex import com.zup.nimbus.core.regex.toFastRegex @@ -64,9 +64,9 @@ class Transition { * */ class DPA ( - private val initial: String, - private val final: String, - private val transitions: Map>, + private val initial: String, + private val final: String, + private val transitions: Map>, ) { object Symbols { const val EMPTY = "∅" diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/network/DefaultHttpClient.kt b/src/commonMain/kotlin/com/zup/nimbus/core/network/DefaultHttpClient.kt index 118ba23..bac67ff 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/network/DefaultHttpClient.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/network/DefaultHttpClient.kt @@ -13,7 +13,11 @@ import io.ktor.http.Headers import io.ktor.http.HttpMethod import kotlin.collections.set -class DefaultHttpClient(engine: HttpClientEngine? = null): com.zup.nimbus.core.network.HttpClient { +class DefaultHttpClient internal constructor ( + engine: HttpClientEngine? = null, +): com.zup.nimbus.core.network.HttpClient { + constructor() : this(null) + private val client = if (engine == null) HttpClient() else HttpClient(engine) override suspend fun sendRequest(request: ServerDrivenRequest): ServerDrivenResponse { diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/network/DefaultViewClient.kt b/src/commonMain/kotlin/com/zup/nimbus/core/network/DefaultViewClient.kt index 88290d2..672087d 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/network/DefaultViewClient.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/network/DefaultViewClient.kt @@ -1,8 +1,7 @@ package com.zup.nimbus.core.network -import com.zup.nimbus.core.log.Logger -import com.zup.nimbus.core.tree.IdManager -import com.zup.nimbus.core.tree.RenderNode +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.tree.dynamic.node.RootNode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers @@ -14,32 +13,26 @@ import kotlinx.coroutines.sync.withLock import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -class DefaultViewClient( - private val httpClient: HttpClient, - private val urlBuilder: UrlBuilder, - private val idManager: IdManager, - private val logger: Logger, - private val platform: String, -) : ViewClient { +class DefaultViewClient(val nimbus: Nimbus) : ViewClient { // used to prevent the ViewClient from launching multiple requests to the same URL in sub-sequent pre-fetches private val mutex = Mutex() // the keys here are in the format $method:$url - private var preFetched = HashMap>() + private var preFetched = HashMap>() private fun createPreFetchKey(request: ViewRequest): String { return "${request.method}:${request.url}" } - private suspend fun fetchView(request: ViewRequest): RenderNode { + private suspend fun fetchView(request: ViewRequest): RootNode { val coreHeaders = mapOf( // "Content-Type" to "application/json", fixme: ktor doesn't like this header - "platform" to platform, + "platform" to nimbus.platform, ) - val url = urlBuilder.build(request.url) + val url = nimbus.urlBuilder.build(request.url) val response: ServerDrivenResponse try { try { - response = httpClient.sendRequest( + response = nimbus.httpClient.sendRequest( ServerDrivenRequest( url = url, method = request.method, @@ -51,17 +44,17 @@ class DefaultViewClient( throw RequestError(e.message) } - if (response.status < FIRST_BAD_STATUS) return RenderNode.fromJsonString(response.body, idManager) + if (response.status < FIRST_BAD_STATUS) return nimbus.nodeBuilder.buildFromJsonString(response.body) throw ResponseError(response.status, response.body) } catch (e: Throwable) { if (request.fallback == null) throw e - logger.error("Failed to perform network request to $url, using the provided fallback view instead. " + + nimbus.logger.error("Failed to perform network request to $url, using the provided fallback view instead. " + "Cause:\n${e.message ?: "Unknown"}") - return request.fallback + return nimbus.nodeBuilder.buildFromJsonMap(request.fallback) } } - override suspend fun fetch(request: ViewRequest): RenderNode { + override suspend fun fetch(request: ViewRequest): RootNode { val key = createPreFetchKey(request) val deferred = preFetched[key] preFetched = HashMap() @@ -98,7 +91,7 @@ class DefaultViewClient( return@async fetchView(request) } catch (e: Throwable) { this.cancel("Error while prefetching.\n${e.message}") - return@async RenderNode.empty() + return@async RootNode() } } } diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/network/ViewClient.kt b/src/commonMain/kotlin/com/zup/nimbus/core/network/ViewClient.kt index ecd0c90..53b6fc5 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/network/ViewClient.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/network/ViewClient.kt @@ -1,8 +1,8 @@ package com.zup.nimbus.core.network -import com.zup.nimbus.core.tree.MalformedComponentError -import com.zup.nimbus.core.tree.MalformedJsonError -import com.zup.nimbus.core.tree.RenderNode +import com.zup.nimbus.core.tree.dynamic.builder.MalformedComponentError +import com.zup.nimbus.core.tree.dynamic.builder.MalformedJsonError +import com.zup.nimbus.core.tree.dynamic.node.RootNode import kotlin.coroutines.cancellation.CancellationException interface ViewClient { @@ -18,7 +18,7 @@ interface ViewClient { */ @Throws(RequestError::class, ResponseError::class, MalformedJsonError::class, MalformedComponentError::class, CancellationException::class) - suspend fun fetch(request: ViewRequest): RenderNode + suspend fun fetch(request: ViewRequest): RootNode /** * Pre-fetches a view (UI tree) from the server and stores it. The next fetch will get the stored value instead of diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/network/ViewRequest.kt b/src/commonMain/kotlin/com/zup/nimbus/core/network/ViewRequest.kt index c9279fb..1111dcf 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/network/ViewRequest.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/network/ViewRequest.kt @@ -1,7 +1,5 @@ package com.zup.nimbus.core.network -import com.zup.nimbus.core.tree.RenderNode - data class ViewRequest( /** * The URL to send the request to. When it starts with "/", it's relative to the BaseUrl. @@ -22,5 +20,5 @@ data class ViewRequest( /** * UI tree to show if an error occurs and the view can't be fetched. */ - val fallback: RenderNode? = null, + val fallback: Map? = null, ) diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/operations/index.kt b/src/commonMain/kotlin/com/zup/nimbus/core/operations/index.kt deleted file mode 100644 index ce66e98..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/operations/index.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.zup.nimbus.core.operations - -import com.zup.nimbus.core.OperationHandler - -internal fun getDefaultOperations(): Map { - return getArrayOperations() + getOtherOperations() + getNumberOperations() + getStringOperations() + - getLogicOperations() -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/regex/FastRegex.kt b/src/commonMain/kotlin/com/zup/nimbus/core/regex/FastRegex.kt index 44234fb..c5fcf4b 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/regex/FastRegex.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/regex/FastRegex.kt @@ -71,4 +71,19 @@ expect class FastRegex(pattern: String) { * @return the new string with the replaced values. */ fun replace(input: String, transform: (MatchGroups) -> String): String + + /** + * Temporary solution for parsing expressions. We'll probably need to reformulate this when revising the + * implementation and extending the grammar. + * + * This replaces every unmatching substring with the transformUnmatching function passed as parameter and every + * matching substring with the transformMatching substring passed as parameter. The result is a list of whatever + * the substrings have been transformed into. + * + * @param input the string to match the regex against. + * @param transformUnmatching a function to transform the unmatched substring into T. + * @param transformMatching a function to transform the matched substring into T. + * @return the list of T with the replaced values. + */ + fun transform(input: String, transformUnmatching: (String) -> T, transformMatching: (MatchGroups) -> T): List } diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/render/ActionEvent.kt b/src/commonMain/kotlin/com/zup/nimbus/core/render/ActionEvent.kt deleted file mode 100644 index 16429b4..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/render/ActionEvent.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.zup.nimbus.core.render - -import com.zup.nimbus.core.tree.RenderNode -import com.zup.nimbus.core.tree.ServerDrivenAction - -data class ActionEvent( - /** - * The action of this event. Use this object to find the name of the action, its properties and metadata. - */ - val action: ServerDrivenAction, - /** - * The name of the event that triggers the action, i.e. the key of the node property that declared it. Example, a - * component "Button" triggers the action found in the property "onPress" when it's pressed. "onPress" is the - * "name" of this event. - */ - val name: String, - /** - * The node (component) containing the action. - */ - val node: RenderNode, - /** - * The view containing the node that declared the action. - */ - val view: ServerDrivenView, -) - diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/render/Renderer.kt b/src/commonMain/kotlin/com/zup/nimbus/core/render/Renderer.kt deleted file mode 100644 index 998899a..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/render/Renderer.kt +++ /dev/null @@ -1,327 +0,0 @@ -package com.zup.nimbus.core.render - -import com.zup.nimbus.core.regex.toFastRegex -import com.zup.nimbus.core.tree.MalformedActionListError -import com.zup.nimbus.core.tree.RenderAction -import com.zup.nimbus.core.tree.RenderNode -import com.zup.nimbus.core.tree.ServerDrivenState -import com.zup.nimbus.core.tree.TreeUpdateMode - -private val statePathRegex = """^(\w+)((?:\.\w+)*)${'$'}""".toFastRegex() - -class Renderer( - private val view: ServerDrivenView, - private val detachedStates: List, - private val getCurrentTree: () -> RenderNode?, - private val replaceCurrentTree: (tree: RenderNode) -> Unit, - private val onFinish: () -> Unit, -) { - private val logger = view.nimbusInstance.logger - private val structuralComponents = view.nimbusInstance.structuralComponents - // fixme: remove this once we have an efficient compilation process that runs before the first render of a node. - // intentional BUG: we're gonna recall the action using its key as the id. If an action has the same name as another - // action in the same node, this will bug out! Example: { onChange: { perform: action } }, - // { onBlur: { perform: action } }: both actions are named "perform" and will be considered the same, although they - // shouldn't. - // Attention: this must be fixed before a stable version is released. - private val memoizedActions = HashMap Unit>>() - - /** - * Resolves the property "value" of "node" recursively. - * - * "value" can be resolved to itself, a function (actions) or an expression result. - * - * @param value the property to resolve. - * @param key the map key. - * @param node the node "value" belongs to. - * @param extraStates use this to declare states that should be implicit. - * @return the resolved value. - */ - @Suppress("NestedBlockDepth") - private fun resolveProperty( - value: Any?, - key: String, - node: RenderNode, - extraStates: List, - ): Any? { - if (value is List<*>) { - if (RenderAction.isActionList(value)) { - try { - val memoized = memoizedActions[node.id] ?: HashMap() - memoizedActions[node.id] = memoized - if (!memoized.containsKey(key)) { - memoized[key] = deserializeActions( - actionList = RenderAction.createActionList(value), - event = key, - node = node, - view = view, - extraStates = extraStates, - resolve = { propertyValue, propertyKey, implicitStates -> - resolveProperty(propertyValue, propertyKey, node, implicitStates) - } - ) - } - return memoized[key] - } catch (error: MalformedActionListError) { - throw RenderingError(error.message) - } - } - return value.map { resolveProperty(it, key, node, extraStates) } - } - if (value is Map<*, *>) return value.mapValues { resolveProperty(it.value, it.key.toString(), node, extraStates) } - val stateHierarchy = (node.stateHierarchy ?: emptyList()) + extraStates - if (value is String && containsExpression(value)) { - return resolveExpressions(value, stateHierarchy, view.nimbusInstance.operations, logger) - } - return value - } - - /** - * Resolves every expression and deserializes every action into functions. - * - * Attention: this operation is not recursive on the node. - * - * @param node the node to resolve. - */ - private fun resolve(node: RenderNode) { - val previousHash = node.properties?.hashCode() ?: 0 - node.properties = node.rawProperties?.mapValues { - resolveProperty(it.value, it.key, node, emptyList()) - } - val nextHash = node.properties?.hashCode() ?: 0 - node.dirty = node.dirty || previousHash != nextHash - node.isRendered = true - } - - private fun computeStateHierarchy(node: RenderNode, parentHierarchy: List) { - if (node.state == null && node.implicitStates?.isEmpty() != false) { - node.stateHierarchy = parentHierarchy - } else { - val newHierarchy = ArrayList() - if (node.state != null) newHierarchy.add(node.state) - if (node.implicitStates != null) newHierarchy.addAll(node.implicitStates) - newHierarchy.addAll(parentHierarchy) - node.stateHierarchy = newHierarchy - } - } - - /* This needs to be recursive because a structural component can yield another structural component, without a - wrapping component. If this was not recursive, this new structural component would never get processed. */ - private fun buildStructuralComponent( - node: RenderNode, - builder: (node: RenderNode) -> List, - stateHierarchy: List, - ): List { - val children = ArrayList() - computeStateHierarchy(node, stateHierarchy) - resolve(node) - val result = builder(node) - result.forEach { - val componentBuilder = structuralComponents[it.component] - if (componentBuilder == null) { - processTreeAndStateHierarchy(it, node.stateHierarchy!!) - children.add(it) - } else { - children.addAll(buildStructuralComponent(it, componentBuilder, node.stateHierarchy!!)) - } - } - return children - } - - private fun unfoldStructuralComponents(node: RenderNode) { - val children = ArrayList() - node.rawChildren?.forEach { child -> - val componentBuilder = structuralComponents[child.component] - if (componentBuilder == null) children.add(child) - else children.addAll(buildStructuralComponent(child, componentBuilder, node.stateHierarchy!!)) - } - if (!node.dirty) { - val previousStructure = node.children?.map { it.id } - val nextStructure = children.map { it.id } - node.dirty = previousStructure != nextStructure - } - node.children = children - } - - /** - * Creates the `node.children` array from the `node.rawChildren`. In most cases, `children` will just be a pointer to - * `rawChildren`, but when the node contains a structural node that needs to be unfolded, its `children` array will be - * a version of its `rawChildren` array where every structural node is replaced by its result. - * - * This also processes every child of `node` according to `shouldProcessStateHierarchy`. Since structural components - * always represent a new structure, they will always be processed with `processTreeAndStateHierarchy` despite the - * value of `shouldProcessStateHierarchy`. - * - * At the moment of writing, there are two structural components: `if` and `foreach`: - * - * If the `rawChildren` contains something like: - * - If - * - Then - * - NodeA - * - Else - * - NodeB - * - * The children will contain only: - * - NodeA or; - * - NodeB, depending on the result of `structuralComponents["if"](node)` - * - * If the `rawChildren` contains something like: - * - Foreach - * - Template - * - * The children will contain something like: - * - ComponentResultingFromIteration1 - * - ComponentResultingFromIteration2 - * - ComponentResultingFromIteration3 - * - and more (or less) depending on the result of `structuralComponents["foreach"](node)` - * - * @param node the node to have its children processed from its rawChildren. - * @param shouldProcessStateHierarchy whether to process the children with `processTreeAndStateHierarchy` (true) or - * `processTree` (false). Makes no difference for structural children, they will always use - * `processTreeAndStateHierarchy`. - */ - private fun createChildrenFromRawChildren(node: RenderNode, shouldProcessStateHierarchy: Boolean) { - var hasStructuralNode = false - - node.rawChildren?.forEach { - val isStructuralNode = structuralComponents.containsKey(it.component) - if (isStructuralNode) { - hasStructuralNode = true - return@forEach - } - if (shouldProcessStateHierarchy) { - processTreeAndStateHierarchy(it, node.stateHierarchy ?: throw NoStateHierarchyError()) - } else { - processTree(it) - } - } - - if (hasStructuralNode) unfoldStructuralComponents(node) - else node.children = node.rawChildren - } - - /** - * Recursively processes the tree, resolving every expression, action or structural component. - * - * This recreates the state hierarchy and must be called whenever the tree structure changes. If the tree structure - * didn't change, call "processTree" instead (faster). - * - * @param node the tree to process. - * @param stateHierarchy the stateHierarchy calculated until now. Initialize this with the states declared outside - * the tree (e.g. global context). This list must be ordered from the state with the highest priority to the lowest. - */ - private fun processTreeAndStateHierarchy(node: RenderNode, stateHierarchy: List) { - computeStateHierarchy(node, stateHierarchy) - resolve(node) - createChildrenFromRawChildren(node, true) - } - - /** - * Recursively processes the tree, resolving every expression, action or structural component. - * - * This will use the state hierarchy that has already been calculated instead of recalculating it. Be sure to only - * call it if the tree structure is maintained, otherwise, call "processTreeAndStateHierarchy" instead (slower). - * - * @param node the tree to process. - */ - private fun processTree(node: RenderNode) { - resolve(node) - createChildrenFromRawChildren(node, false) - } - - /** - * Replaces the current tree with a new one. This processes the tree but doesn't trigger a re-render. - * - * @param tree the new tree to render. - */ - private fun replaceEntireTree(tree: RenderNode) { - replaceCurrentTree(tree) - processTreeAndStateHierarchy(tree, detachedStates) - } - - /** - * Updates a branch of the current tree with new content. This update can be either a full replacement of the branch - * or an update to its children. This processes only the node "anchor" refers to, leaving the rest of the tree intact. - * It doesn't trigger a re-render. - * - * @param newBranch the new branch to add to the tree. - * @param anchor the id of the node to replace if the mode is "ReplaceItself" or the id of the node to receive the - * new child otherwise. - * @param mode dictates how to insert the newBranch into the current tree. - */ - private fun updateBranch(newBranch: RenderNode, anchor: String, mode: TreeUpdateMode) { - val currentTree = getCurrentTree() ?: throw EmptyViewError() - val parent = currentTree.update(newBranch, anchor, mode) ?: throw AnchorNotFoundError(anchor) - processTreeAndStateHierarchy(parent, parent.stateHierarchy ?: throw InvalidTreeError()) - } - - /** - * Updates the current tree with new content and triggers a re-render event. - * - * @param tree the updates to the current tree. Can be either a small update or a full tree replacement. - * @param anchor the id of the node to replace if the mode is "ReplaceItself" or the id of the node to receive the - * new child otherwise. Defaults to the id of the root node. - * @param mode dictates how to insert "tree" into the current tree. Defaults to "ReplaceItself". - * @throws UnexpectedRootStructuralComponent when th root node is a structural component. - */ - @Throws(UnexpectedRootStructuralComponent::class) - fun paint(tree: RenderNode, anchor: String? = null, mode: TreeUpdateMode = TreeUpdateMode.ReplaceItself) { - if (structuralComponents.containsKey(tree.component)) { - throw UnexpectedRootStructuralComponent() - } - try { - val currentTree = getCurrentTree() - val shouldReplaceEntireTree = currentTree == null || - (mode == TreeUpdateMode.ReplaceItself && (anchor == null || anchor == currentTree.id)) - - if (shouldReplaceEntireTree) replaceEntireTree(tree) - else updateBranch(tree, anchor ?: currentTree!!.id, mode) - - onFinish() - } catch (error: RenderingError) { - logger.error(error.message) - } - } - - /** - * Reprocesses the entire tree without changing its structure. Useful for updating values of states that live outside - * the tree, example: the global state. - */ - fun refresh() { - val current = getCurrentTree() ?: return logger.error("Can't refresh blank ServerDrivenView.") - processTree(current) - onFinish() - } - - /** - * Changes the current state, reprocesses every node that could've been affected and triggers a re-render event. - * - * If the state is not accessible from "sourceNode" no re-render will happen and the error will be logged. - * - * If the node to update is detached from the tree (global state, for instance). The UI will not be updated from here - * since it is expected that an outside listener will take care of it. - * - * @param sourceNode the node where the setState originated from. This is important because each node can see/modify - * a subset of all states available. Moreover, there could be states with the same name and we must know which one - * has been declared closest to the node (shadowing). - * @param path the state path to alter. If we want to alter the state with id "myState", for instance, the path would - * be "myState". If we wanted to alter the property "bar" in "foo" inside "myState", the path would be - * "myState.foo.bar". - * @param newValue the new value to set for the state indicated by "path". - */ - fun setState(sourceNode: RenderNode, path: String, newValue: Any?) { - try { - val matchResult = statePathRegex.findWithGroups(path) ?: throw InvalidStatePathError(path) - val (stateId, statePath) = matchResult.destructured - val stateHierarchy = sourceNode.stateHierarchy ?: throw InvalidTreeError() - val state = stateHierarchy.find { it.id == stateId } ?: throw StateNotFoundError(path, sourceNode.id) - state.set(newValue, statePath) - if (state.parent != null) { - processTree(state.parent) - onFinish() - } - } catch (error: RenderingError) { - logger.error(error.message) - } - } -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/render/ServerDrivenView.kt b/src/commonMain/kotlin/com/zup/nimbus/core/render/ServerDrivenView.kt deleted file mode 100644 index a952931..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/render/ServerDrivenView.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.zup.nimbus.core.render - -import com.zup.nimbus.core.ServerDrivenNavigator -import com.zup.nimbus.core.Nimbus -import com.zup.nimbus.core.tree.ObservableStateListener -import com.zup.nimbus.core.tree.RemovableObservableStateListener -import com.zup.nimbus.core.tree.RenderNode -import com.zup.nimbus.core.tree.ServerDrivenNode - -typealias Listener = (tree: ServerDrivenNode) -> Unit - -class ServerDrivenView( - /** - * The instance of Nimbus that created this ServerDrivenView. - */ - val nimbusInstance: Nimbus, - /** - * A function to get the navigator that spawned this view. - * - * Attention: this is a function so we can prevent a cyclical reference between Kotlin Native and Swift. Replacing - * this with a direct reference will cause memory leaks. - */ - val getNavigator: () -> ServerDrivenNavigator, - /** - * A description for this view. Suggestion: the URL used to load the content of this view or "json", if a local json - * string was used to load it. - */ - val description: String? = null, -) { - /** - * The currently rendered tree. - */ - private var current: RenderNode? = null - - /** - * The listener that observes changes in the current tree. This will be called only for changes that requires - * re-rendering. - */ - private var listener: Listener? = null - - /** - * Stores the function to make this view stop listening to the Global State. - */ - private var removeGlobalStateListener: RemovableObservableStateListener? = null - - /** - * The Renderer for this view. Use it to change the UI tree or a view state. - */ - val renderer = Renderer( - view = this, - detachedStates = listOf(nimbusInstance.globalState), - getCurrentTree = { current }, - replaceCurrentTree = { current = it }, - onFinish = { runListeners() }, - ) - - init { - removeGlobalStateListener = nimbusInstance.globalState.onChange { - if (current != null) renderer.refresh() - } - } - - private fun runListeners() { - listener?.let { it(current ?: throw EmptyViewError()) } - } - - /** - * Observes for changes in the current tree. Everytime a change that requires a re-render is made, the listener - * passed as parameter will be called with the current tree. - * - * If there's already something rendered in this view, the listener is automatically called with it. - * - * The ServerDrivenView accepts only one observer. A second call to this method replaces the current listener. To - * remove the listener call this method with null. - * - * Note: - * The tree received as parameter by the listener is a ServerDrivenNode, which is immutable. It wouldn't take much for - * the developer to realize that the concrete type of this tree is a RenderNode, which is mutable. We advise every - * developer not to cast this to RenderNode and modify it, since this could cause unpredictable behavior. - * - * @param listener the function to call every time the tree needs to be re-rendered. This receives the current tree as - * parameter. - */ - fun onChange(listener: Listener?) { - this.listener = listener - if (current != null && listener != null) listener(current ?: return) - } - - /** - * Destroys this view by properly removing every reference that could become invalid. - */ - fun destroy() { - listener = null - removeGlobalStateListener?.let { it() } - } -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/render/action.kt b/src/commonMain/kotlin/com/zup/nimbus/core/render/action.kt deleted file mode 100644 index 24a53f2..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/render/action.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.zup.nimbus.core.render - -import com.zup.nimbus.core.tree.RenderAction -import com.zup.nimbus.core.tree.RenderNode -import com.zup.nimbus.core.tree.ServerDrivenState - -private fun resolveActionPropertiesAndMetadata( - action: RenderAction, - states: List, - resolve: (value: Any?, key: String, extraStates: List) -> Any?, -) { - fun mapToResolved(entry: Map.Entry): Any? { - /* we should never deserialize ServerDrivenNodes (components) within actions. This would cause all their - actions to run in the context of this node, which is wrong, they should be run in the context of their own - parent node. */ - return if (entry.value is Map<*, *> && RenderNode.isServerDrivenNode(entry.value as Map<*, *>)) entry.value - else resolve(entry.value, entry.key, states) - } - - action.properties = action.rawProperties?.mapValues { mapToResolved(it) } - action.metadata = action.rawMetadata?.mapValues { mapToResolved(it) } -} - -/** - * Deserializes a list of ServerDrivenAction into a function. - * - * When an action is deserialized, if there's any onActionRendered handler for it, it's run. - * - * @param actionList the list of actions to parse into a function. - * @param event name of the event that triggers the actionList, i.e. the key of the map entry. This will act as the id - * of the implicit state (if any). - * @param node the node that declared the actionList. - * @param view the view holding the node. - * @param extraStates states that should be accounted even though they are not part of the node's state hierarchy. Use - * this to deal with implicit states. The extra states must be in descending order of priority and they all have higher - * priority than `node.stateHierarchy`. This is used when both the parent action and sub action declare implicit - * states). - * @param resolve a function to parse all sub-actions and expressions. - */ -internal fun deserializeActions( - actionList: List, - event: String, - node: RenderNode, - view: ServerDrivenView, - extraStates: List, - resolve: (value: Any?, key: String, extraStates: List) -> Any?, -): (implicitContextValue: Any?) -> Unit { - if (!node.isRendered) { - val missingHandlers = ArrayList() - actionList.forEach { action -> - val onRenderedHandler = view.nimbusInstance.onActionRendered[action.action] - val executionHandler = view.nimbusInstance.actions[action.action] - if (onRenderedHandler != null) { - resolveActionPropertiesAndMetadata(action, emptyList(), resolve) - onRenderedHandler(ActionEvent(action, event, node, view)) - } - if (executionHandler == null) missingHandlers.add(action.action) - } - if (missingHandlers.isNotEmpty()) { - view.nimbusInstance.logger.warn( - "The following actions used in component with id ${node.id} don't have any associated " + - "handler: ${missingHandlers.distinct().joinToString(", ")}" - ) - } - } - - return { implicitStateValue -> - actionList.forEach { action -> - val handler = view.nimbusInstance.actions[action.action] - if (handler == null) { - view.nimbusInstance.logger.error( - """Action with name "${action.action}" has been triggered, but no associated handler has been found.""", - ) - } else { - val newExtraStates = - if (implicitStateValue == null) extraStates - else listOf(ServerDrivenState(event, implicitStateValue, node)) + extraStates - resolveActionPropertiesAndMetadata(action, newExtraStates, resolve) - val actionEvent = ActionEvent(action, event, node, view) - handler(actionEvent) - view.nimbusInstance.actionObservers.forEach { it(actionEvent) } - } - } - } -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/render/error.kt b/src/commonMain/kotlin/com/zup/nimbus/core/render/error.kt deleted file mode 100644 index 15d8f51..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/render/error.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.zup.nimbus.core.render - -open class RenderingError(override val message: String, illegal: Boolean = false) - : Error("Aborting rendering process. $message" + - if (illegal) " This is probably a bug within the Server Driven Lib. Please, report it to the developer team." else "") - -class AnchorNotFoundError(anchor: String) - : RenderingError("""The current tree has no node identified by "$anchor".""") - -class EmptyViewError - : RenderingError("The current tree was null while trying to update one of its branches.", true) - -class InvalidTreeError: RenderingError("The current UI tree reached an illegal state.", true) - -class InvalidStatePathError(path: String): RenderingError("""The path "$path" is not a valid state path.""") - -class StateNotFoundError(stateId: String, componentId: String) - : RenderingError("""Could not find state "$stateId" from the component with id "$componentId"""") - -class NoStateHierarchyError: RenderingError("Expected node to have a state hierarchy.", true) - -class UnexpectedRootStructuralComponent: RenderingError("A structural component can't be the root of a UI " + - "tree. Please wrap it under a UI component node.") diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/render/expression.kt b/src/commonMain/kotlin/com/zup/nimbus/core/render/expression.kt deleted file mode 100644 index 0c386ed..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/render/expression.kt +++ /dev/null @@ -1,184 +0,0 @@ -@file:Suppress("TooGenericExceptionThrown") // todo: verify -package com.zup.nimbus.core.render - -import com.zup.nimbus.core.OperationHandler -import com.zup.nimbus.core.log.Logger -import com.zup.nimbus.core.regex.toFastRegex -import com.zup.nimbus.core.regex.matches -import com.zup.nimbus.core.regex.replace -import com.zup.nimbus.core.tree.ServerDrivenState -import com.zup.nimbus.core.utils.untypedValueOfPath - -//Do not remove the Redundant character escape '\}' in RegExp, this causes error when using android regex implementation -private val expressionRegex = """(\\*)@\{(([^'}]|('([^'\\]|\\.)*'))*)\}""".toFastRegex() -private val fullMatchExpressionRegex = """^@\{(([^'}]|('([^'\\]|\\.)*'))*)\}$""".toFastRegex() -private val literalRegex = """^\d+((.)|(.\d+)?)$""".toFastRegex() -private val operationRegex = """^(\w+)\((.*)\)$""".toFastRegex() -private val stateReferenceRegex = """^[\w\d_]+(\[\d+\])*(\.([\w\d_]+(\[\d+\])*))*$""".toFastRegex() -private val pathRegex = """^([^\.\[\]]+)\.?(.*)""".toFastRegex() - -private val dpaTransitions: Map> = mapOf( - "initial" to listOf( - Transition(""",|$""", true, null, null, "final"), // end of parameter - Transition("(", "(", null, "insideParameterList"), // start of a parameter list - Transition("""'([^']|(\\.))*'""", true, null, null, "initial"), // strings - Transition("""[^)]""", true, null, null, "initial"), // general symbols - ), - "insideParameterList" to listOf( - Transition("(", "(", null, "insideParameterList"), // start of another parameter list - // end of a parameter list, check if still inside a parameter list - Transition(")", null, "(", "isParameterListOver"), - Transition("""'([^']|(\\.))*'""", true, null, null, "insideParameterList"), // strings - Transition(".", true, null, null, "insideParameterList"), // general symbols - ), - "isParameterListOver" to listOf( - Transition(null, DPA.Symbols.EMPTY, "initial"), // end of parameter list, go back to initial state - // still inside a parameter list, go back to state "insideParameterList" - Transition(null, null, "insideParameterList"), - ), -) - -private val dpa = DPA("initial", "final", dpaTransitions) - -private fun parseParameters(parameterString: String): List { - val parameters = mutableListOf() - var position = 0 - - while (position < parameterString.length) { - val match = dpa.match(parameterString.substring(position)) - ?: throw Error("wrong format for parameters: $parameterString") - parameters.add(match.removeSuffix(",").trim()) - position += match.length - } - - return parameters -} - -private fun getStateValue(path: String, stateHierarchy: List, logger: Logger): Any? { - if (!path.matches(stateReferenceRegex)) { - throw Error("invalid path \"$path\". Please, make sure your variable names contain only letters, numbers and the " + - "symbol \"_\". To access substructures use \".\" and to access array indexes use \"[index]\".") - } - - val pathMatch = pathRegex.findWithGroups(path) ?: return null - val (stateId, statePath) = pathMatch.destructured - - if (stateId == "null") return null - - val state = stateHierarchy.find { it.id == stateId } ?: throw Error("Couldn't find state with id \"$stateId\"") - - if (statePath.isNotEmpty() && statePath.isNotBlank()) { - return try { - return untypedValueOfPath(state.value, statePath) - } catch (error: Throwable) { - error.message?.let { - logger.warn(it) - } - null - } - } - return state.value -} - -private fun getLiteralValue(literal: String): Any? { - when (literal) { - "true" -> return true - "false" -> return false - "null" -> return null - } - - if (literal.matches(literalRegex)) { - if (literal.contains(".")) return literal.toDouble() - return literal.toInt() - } - - if (literal.startsWith("'") && literal.endsWith("'")) { - return literal.drop(1).dropLast(1).replace("""\'""", "'") - } - - return null -} - -private fun getOperationValue( - operation: String, - stateHierarchy: List, - operationHandlers: Map, - logger: Logger, -): Any? { - val match = operationRegex.findWithGroups(operation) - ?: throw Error("invalid operation in expression: $operation") - - val (operationName, paramString) = match.destructured - if (operationHandlers[operationName] == null) { - throw Error("operation with name \"$operationName\" doesn't exist.") - } - - val params = parseParameters(paramString) - val resolvedParams = params.map { param -> - evaluateExpression(param, stateHierarchy, operationHandlers, logger) - } - - val operationHandler = operationHandlers[operationName] - if (operationHandler != null) { - return operationHandler(resolvedParams) - } - return null -} - -private fun evaluateExpression( - expression: String, - stateHierarchy: List, - operationHandlers: Map, - logger: Logger, -): Any? { - val literalValue = getLiteralValue(expression) - if (literalValue != null) return literalValue - - val isOperation = expression.contains("(") - if (isOperation) return getOperationValue(expression, stateHierarchy, operationHandlers, logger) - - return getStateValue(expression, stateHierarchy, logger) -} - -fun containsExpression(value: String): Boolean { - return expressionRegex.containsMatchIn(value) -} - -fun resolveExpressions( - value: String, - stateHierarchy: List, - operationHandlers: Map, - logger: Logger, -): Any? { - val fullMatch = fullMatchExpressionRegex.findWithGroups(value) - if (fullMatch != null) { - val (expression) = fullMatch.destructured - return try { - return evaluateExpression(expression, stateHierarchy, operationHandlers, logger) - } catch (error: Throwable) { - error.message?.let { - logger.warn(it) - } - null - } - } - - try { - return value.replace(expressionRegex) { - val (slashes, actualExpression) = it.destructured - val isExpressionEscaped = slashes.length % 2 == 1 - val escapedSlashes = slashes.replace("""\\""", """\""") - - if (isExpressionEscaped) return@replace "${escapedSlashes.dropLast(1)}@{$actualExpression}" - - val expressionValue = evaluateExpression(actualExpression, stateHierarchy, operationHandlers, logger) - ?: return@replace escapedSlashes - return@replace "$escapedSlashes${expressionValue}" - } - } catch (error: Throwable) { - error.message?.let { - logger.warn(it) - } - return value.replace(expressionRegex, "") - } -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/scope/CommonScope.kt b/src/commonMain/kotlin/com/zup/nimbus/core/scope/CommonScope.kt new file mode 100644 index 0000000..cf2d7fc --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/scope/CommonScope.kt @@ -0,0 +1,22 @@ +package com.zup.nimbus.core.scope + +import com.zup.nimbus.core.ServerDrivenState + +open class CommonScope( + override val states: List?, + override var parent: Scope? = null, +): Scope { + private val storage = mutableMapOf() + + override fun get(key: String): Any? { + return storage[key] ?: parent?.get(key) + } + + override fun set(key: String, value: Any) { + storage[key] = value + } + + override fun unset(key: String) { + storage.remove(key) + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/scope/LazilyScoped.kt b/src/commonMain/kotlin/com/zup/nimbus/core/scope/LazilyScoped.kt new file mode 100644 index 0000000..787283e --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/scope/LazilyScoped.kt @@ -0,0 +1,28 @@ +package com.zup.nimbus.core.scope + +/** + * An entity that needs a scope, but the scope can't be known upon construction, i.e. it must be lazily initialized. + * LazilyScoped entities must also be able to copy themselves as long as a scope hasn't been assigned yet. + */ +interface LazilyScoped { + /** + * Initializes this entity with the given scope. + * + * @throws DoubleInitializationError if called more than once. + */ + fun initialize(scope: Scope) + /** + * Deep copies this entity. This can only be called before initialize. + * + * @throws CloneAfterInitializationError if called after the initialization. + */ + fun clone(): T +} + +class DoubleInitializationError: IllegalStateException( + "Can't initialize this LazilyScoped instance because it has already been initialized!" +) + +class CloneAfterInitializationError: IllegalStateException( + "Can't clone this LazilyScoped instance because it has already been initialized!!" +) diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/scope/Scope.kt b/src/commonMain/kotlin/com/zup/nimbus/core/scope/Scope.kt new file mode 100644 index 0000000..4c71dd9 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/scope/Scope.kt @@ -0,0 +1,84 @@ +package com.zup.nimbus.core.scope + +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.ServerDrivenState +import com.zup.nimbus.core.ServerDrivenView +import com.zup.nimbus.core.tree.ServerDrivenEvent +import com.zup.nimbus.core.tree.ServerDrivenNode + +/** + * Most things in Nimbus occurs within a scope. An event, for instance, can be triggered in the scope of button click, + * which is in the scope of a component (node). + * + * The scope allows us to recover important information about the event, component, view and even access objects stored + * in the Nimbus Scope, like the logger and the HttpClient. + * + * The Scope is nothing more than a tree, if we don't find what we're looking for in the current scope, we look into + * the parent scope, until we get to the root. + * + * Normally, but not necessarily, a Scope tree will follow a format like this: Nimbus > View > RootNode > Node A > + * Node B > Event A > Event B. + * + * A Scope is also responsible for holding states, which can be set by the operation `setState` and read by expressions. + * Global states will be in `Nimbus`, states specific to a view in `ServerDrivenView` and local states in their + * respective nodes. + * + * A Scope can also work as a storage unit through its methods get and set. This is useful for registering dependencies + * in the UI Layer. + */ +interface Scope { + /** + * The parent scope. Null if this is the root node. + * + * Attention: considering a ServerDrivenNode, this is not necessarily the parent UI Node. + */ + var parent: Scope? + /** + * The states associated to this scope. + */ + val states: List? + /** + * Gets a value from this scope. If not found, it will look into the parent scope, recursively, until the root is + * reached. + */ + fun get(key: String): Any? + /** + * Sets a value to this scope. + */ + fun set(key: String, value: Any) + /** + * Removes a value from this scope. + */ + fun unset(key: String) +} + +/** + * Returns the closest Scope with the specified type. + */ +internal inline fun Scope.closestScopeWithType(): T? { + var current: Scope? = this + while (current != null && current !is T) current = current.parent + return current?.let { current as T } +} + +/** + * Returns the closest state with the specified id. + */ +fun Scope.closestState(id: String): ServerDrivenState? { + return states?.find { it.id == id } ?: parent?.closestState(id) +} + +/** + * Gets a string representing the full path from the root scope to this scope. + */ +fun Scope.getPathToScope(): String { + val pathToParent = this.parent?.let { "${it.getPathToScope()} > " } ?: "" + val thisIdentifier = when (this) { + is ServerDrivenNode -> "${this.id} (${this.component})" + is ServerDrivenEvent -> this.name + is ServerDrivenView -> this.description ?: "Unknown View" + is Nimbus -> "Nimbus instance" + else -> pathToParent + } + return "$pathToParent$thisIdentifier" +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/scope/StateOnlyScope.kt b/src/commonMain/kotlin/com/zup/nimbus/core/scope/StateOnlyScope.kt new file mode 100644 index 0000000..8146689 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/scope/StateOnlyScope.kt @@ -0,0 +1,19 @@ +package com.zup.nimbus.core.scope + +import com.zup.nimbus.core.ServerDrivenState + +/** + * A simple scope to group up server driven nodes with common states without having to create a new node. Used mostly by + * the ForEach component when making the states "item" and "index" available to its children. + */ +class StateOnlyScope(override var parent: Scope?, override val states: List?): Scope { + override fun get(key: String) = parent?.get(key) + + override fun set(key: String, value: Any) { + parent?.set(key, value) + } + + override fun unset(key: String) { + parent?.unset(key) + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/ObservableState.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/ObservableState.kt deleted file mode 100644 index 5deae85..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/tree/ObservableState.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.zup.nimbus.core.tree - -typealias ObservableStateListener = (value: Any?) -> Unit -typealias RemovableObservableStateListener = () -> Unit - -class ObservableState(id: String, value: Any?): ServerDrivenState(id, value, null) { - private val listeners = ArrayList() - - /** - * Listens to changes to this state's value. - * - * @param listener the function to run when the value of this state changes. - * @return a function to remove this listener. - */ - fun onChange(listener: ObservableStateListener): RemovableObservableStateListener { - listeners.add(listener) - return { listeners.remove(listener) } - } - - override fun set(newValue: Any?, path: String) { - // if the new value is a map or list, it must be mutable. - var mutableValue = newValue - if (newValue is Map<*, *> && newValue !is MutableMap<*, *>) mutableValue = newValue.toMutableMap() - else if (newValue is List<*> && newValue !is MutableList<*>) mutableValue = newValue.toMutableList() - // super - super.set(mutableValue, path) - // notify - listeners.forEach { it(value) } - } - - fun set(newValue: Any?) { - set(newValue, "") - } -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/RenderAction.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/RenderAction.kt deleted file mode 100644 index 9cb27fa..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/tree/RenderAction.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.zup.nimbus.core.tree - -import com.zup.nimbus.core.utils.UnexpectedDataTypeError -import com.zup.nimbus.core.utils.valueOfKey - -data class RenderAction( - override val action: String, - /** - * Stores the properties of this action after they've been processed, i.e. after the expressions have been resolved - * and actions deserialized. - */ - override var properties: Map?, - /** - * Stores the original properties of this action, before any processing. This can contain expressions in their - * string form and actions in their Object form (Action). - */ - val rawProperties: Map?, - /** - * Stores the original metadata of this action, before any processing. This can contain expressions in their - * string form. - */ - val rawMetadata: Map?, - /** - * Stores the metadata of this action after it's been processed, i.e. after the expressions have been resolved. - */ - override var metadata: Map?, -): ServerDrivenAction { - companion object { - private fun isAction(maybeAction: Any?): Boolean { - return maybeAction is Map<*, *> && maybeAction.containsKey("_:action") - } - - fun isActionList(maybeActionList: List<*>): Boolean { - return maybeActionList.isNotEmpty() && isAction(maybeActionList.first()!!) - } - - fun createActionList(actions: List<*>): List { - try { - return actions.map { - RenderAction( - action = valueOfKey(it, "_:action"), - rawProperties = valueOfKey(it, "properties"), - rawMetadata = valueOfKey(it, "metadata"), - properties = null, - metadata = null, - ) - } - } catch (e: UnexpectedDataTypeError) { - throw MalformedActionListError(e.message) - } - } - } -} - - diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/RenderNode.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/RenderNode.kt deleted file mode 100644 index cdc42a8..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/tree/RenderNode.kt +++ /dev/null @@ -1,244 +0,0 @@ -package com.zup.nimbus.core.tree - -import com.zup.nimbus.core.utils.UnexpectedDataTypeError -import com.zup.nimbus.core.utils.transformJsonObjectToMap -import com.zup.nimbus.core.utils.valueOfKey -import com.zup.nimbus.core.utils.valueOfPath -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject - -class RenderNode( - override val id: String, - override val component: String, - /** - * Stores the properties of this component after they've been processed, i.e. after the expressions have been resolved - * and actions deserialized. - */ - override var properties: Map?, - /** - * Set of children of this node before they're processed by structural components. - */ - var rawChildren: List?, - /** - * Set of children of this component after they're processed by structural components. Since most of the time there - * won't be any structural components, this will normally just be a pointer to `rawChildren`. - */ - override var children: List?, - /** - * Stores the original properties of this component, before any processing. This can contain expressions in their - * string form and actions in their Object form (Action). - */ - var rawProperties: Map?, - /** - * All states accessible by this component in descending order of priority, i.e. the first element will be the state - * of highest priority and the last element will be the state of lowest priority. - * - * stateHierarchy will be null if this node has not yet been processed by the renderer. - */ - var stateHierarchy: List?, - implicitStates: Map?, - stateId: String?, - stateValue: Any?, - override var dirty: Boolean = true, -): ServerDrivenNode { - companion object { - /** - * Creates a RenderNode from a Json string. - * - * @param json the json string to deserialize into a RenderNode. - * @param idManager the idManager to use for generating ids for components without ids. - * @return the resulting RenderNode. - * @throws MalformedJsonError if the string is not a valid json. - * @throws MalformedComponentError when a component node contains unexpected data. - */ - @Throws(MalformedJsonError::class, MalformedComponentError::class) - fun fromJsonString(json: String, idManager: IdManager): RenderNode { - val jsonObject: JsonObject - try { - jsonObject = Json.decodeFromString(json) - } catch (e: Throwable) { - throw MalformedJsonError("The string provided is not a valid json.") - } - return fromJsonObject(jsonObject, idManager) - } - - /** - * Creates a RenderNode from a JsonObject. - * @param jsonObject the json object to deserialize into a RenderNode. - * @param idManager the idManager to use for generating ids for components without ids. - * @return the resulting RenderNode. - * @throws MalformedComponentError when a component node contains unexpected data. - */ - @Throws(MalformedComponentError::class) - fun fromJsonObject(jsonObject: JsonObject, idManager: IdManager): RenderNode { - return fromMap(transformJsonObjectToMap(jsonObject), idManager) - } - - /** - * Creates a RenderNode from a Map. - * @param map the map to deserialize into a RenderNode. - * @param idManager the idManager to use for generating ids for components without ids. - * @return the resulting RenderNode. - * @throws MalformedComponentError when a component node contains unexpected data. - */ - @Throws(MalformedComponentError::class) - fun fromMap(map: Map, idManager: IdManager, jsonPath: String = "$"): RenderNode { - val originalId: String? = valueOfKey(map, "id") - try { - return RenderNode( - id = originalId ?: idManager.next(), - component = valueOfKey(map, "_:component"), - stateId = valueOfPath(map, "state.id"), - stateValue = valueOfPath(map, "state.value"), - rawProperties = valueOfKey(map, "properties"), - rawChildren = valueOfKey>?>(map, "children")?.mapIndexed { index, value -> - fromMap(value, idManager, "$jsonPath.children[:$index]") - }, - children = null, - stateHierarchy = null, - properties = null, - implicitStates = null, - ) - } catch (e: UnexpectedDataTypeError) { - throw MalformedComponentError(originalId, jsonPath, e.message) - } - } - - fun empty(): RenderNode { - return RenderNode("", "", null, null, null, null, - null, null, null, null) - } - - /** - * Verifies if the given map is a ServerDrivenNode. - */ - fun isServerDrivenNode(maybeNode: Any?): Boolean { - return maybeNode is Map<*, *> && maybeNode.containsKey("_:component") - } - } - - /** - * The state declared by this component. Null if it doesn't declare a state. - */ - val state: ServerDrivenState? = if (stateId == null) null else ServerDrivenState(stateId, stateValue, this) - - val implicitStates: List? = implicitStates?.map { ServerDrivenState(it.key, it.value, this) } - - /** - * True if this node has been rendered at least once. - */ - var isRendered = false - - /** - * Replaces the node with id "idOfNodeToReplace" of this tree by "newNode". - * - * @param newNode the node to be inserted into the tree. - * @param idOfNodeToReplace the id of the node to replace with "newNode". If this is the root node or if it doesn't - * exist, the tree is not altered. - * @return the node that received "newNode" as a child, i.e. its parent. This will be null if "idOfNodeToReplace" - * was the root node or if it wasn't found. - */ - private fun replace(newNode: RenderNode, idOfNodeToReplace: String): RenderNode? { - if (this.id == idOfNodeToReplace) return null - - fun findAndReplaceChild(parentNode: RenderNode, newNode: RenderNode, idOfNodeToReplace: String): RenderNode? { - try { - val children = requireNotNull(parentNode.rawChildren) - if (children.isEmpty()) @Suppress("TooGenericExceptionThrown") throw Error() // todo: verify - - val indexToReplace = children.indexOfFirst { child -> child.id == idOfNodeToReplace } - if (indexToReplace >= 0) { - val newChildren = children.toMutableList() - newChildren[indexToReplace] = newNode - parentNode.rawChildren = newChildren - return parentNode - } - - for (child in children) { - val parent = findAndReplaceChild(child, newNode, idOfNodeToReplace) - if (parent != null) return parent - } - - return null - } catch (exception: Throwable) { - return null - } - } - - return findAndReplaceChild(this, newNode, idOfNodeToReplace) - } - - /** - * Inserts "newNode" into the tree by adding it to the node with id "idOfParentNode". The new node will be inserted - * according to the parameter "mode". - * - * @param newNode the node to be inserted into the tree. - * @param idOfParentNode the id of the node to receive "newNode" as a child. If no node with this id exists, the tree - * is not altered. - * @param mode dictates how to insert "newNode" into the node identifies by "idOfParentNode". - * TreeUpdateMode.ReplaceItself is not acceptable here. - * @return the node that received "newNode" as one of its child, i.e. its parent, the node identified by - * "idOfParentNode". If "idOfParentNode" wasn't found in the tree, null is returned. - */ - private fun insert(newNode: RenderNode, idOfParentNode: String, mode: TreeUpdateMode): RenderNode? { - val target = if (this.id == idOfParentNode) this else findById(idOfParentNode) ?: return null - val children = target.rawChildren ?: listOf() - - when(mode) { - TreeUpdateMode.Append -> target.rawChildren = children + listOf(newNode) - TreeUpdateMode.Prepend -> target.rawChildren = listOf(newNode) + children - TreeUpdateMode.Replace -> target.rawChildren = listOf(newNode) - else -> return null - } - - return target - } - - /** - * Inserts "newNode" into the tree. "newNode" can either replace an existing node or be added to its children. - * - * If "mode" is "ReplaceItself", "newNode" will replace the node with id "anchor". In this case, "anchor" must not - * refer to the root node and there must be a node with this id in the tree, otherwise, the tree will be left - * unchanged and "null" will be returned. In successful operations, the return value is the node that received - * "newNode" as one of its children, i.e. its parent. - * - * If "mode" is "Replace", "Append" or "Prepend", "newNode" will be added as a child of the node identified by - * "anchor". If there's no node with id "anchor", the tree will be left unchanged and null will be returned. In - * successful operations, the return value is the node that received "newNode" as one of its children, i.e. its - * parent, the node identified by "anchor". - * - * @param newNode the node to be inserted into the tree. - * @param anchor the id of the node to replace or receive "newNode" as a child. - * @param mode dictates how to insert "newNode" into the tree. - * @return the parent of "newNode" if the operation is successful. Null otherwise. - */ - fun update(newNode: RenderNode, anchor: String, mode: TreeUpdateMode): RenderNode? { - return if (mode == TreeUpdateMode.ReplaceItself) replace(newNode, anchor) else insert(newNode, anchor, mode) - } - - /** - * Finds the node identified by "id". If there's no such node in the tree, null is returned. - * - * @param id the id to look for. - * @return the node found or null. - */ - fun findById(id: String): RenderNode? { - try { - val children = requireNotNull(this.rawChildren) - if (id.isBlank() || id.isEmpty()) return null - - var i = 0 - var parent: RenderNode? = null - while (i < children.size && parent == null) { - val child = children[i] - parent = if (child.id == id) child else child.findById(id) - i++ - } - - return parent - } catch (error: Throwable) { - return null - } - } -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenAction.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenAction.kt index 00ce467..46db834 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenAction.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenAction.kt @@ -1,11 +1,28 @@ package com.zup.nimbus.core.tree -interface ServerDrivenAction { +import com.zup.nimbus.core.ActionHandler +import com.zup.nimbus.core.dependency.Dependent + +/** + * Represents an action of the original json, i.e. an instruction to execute a function. + * + * The actions in a json are represented as follows (Typescript): + * interface Action { + * "_:action": string, // the action identifier, equivalent to "name" in this class + * properties?: Record, // the properties of the action + * metadata?: Record, // the metadata of the action + * } + */ +interface ServerDrivenAction: Dependent { /** * Identifies the action to execute. This follows the pattern "namespace:name", where "namespace:" is optional. * Actions without a namespace are core actions. */ - val action: String + val name: String + /** + * The function to run when the action is triggered (execution). + */ + val handler: ActionHandler /** * The property map for this action. If this component has no properties, this will be null or an empty map. */ diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenEvent.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenEvent.kt new file mode 100644 index 0000000..b07f8cb --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenEvent.kt @@ -0,0 +1,51 @@ +package com.zup.nimbus.core.tree + +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.ServerDrivenView +import com.zup.nimbus.core.scope.Scope + +/** + * ServerDrivenEvents represent a list of actions in the original json. + * + * A ServerDrivenEvent is a scope that always create a state with the same name as the key of the property who had the + * list of actions as its value. The value of this state can be changed through the method run(Any?). + * + * Example: the onChange event of a TextInput will create the state "onChange" with null as its initial value. When the + * user types in the text input created in the UI Layer, it must call run with the new value of the input, making the + * information available for every action within this event through the state "onChange". + * + * Since the event always create a state, it is possible that an event overwrites another state. For instance, if + * you have a root state called "onPress" and a button component with an event with the same name, the root state + * "onPress" won't be accessible from within the actions of the event. Renaming the root state would solve this issue. + */ +interface ServerDrivenEvent: Scope { + /** + * The name of the event, i.e. the key for the property where the value was the list of actions that compose this + * event. Examples of common event names: "onPress", "onClick", "onInit", "onSuccess", "onChange". + */ + val name: String + /** + * The node that originated this event. + */ + val node: ServerDrivenNode + /** + * The view that originated this event. + */ + val view: ServerDrivenView + /** + * The nimbus instance that originated this event. + */ + val nimbus: Nimbus + /** + * The actions contained in this event + */ + val actions: List + /** + * Runs the current event by triggering all actions contained in it. + */ + fun run() + /** + * Sets the current value of the state created by this event and then runs it. + */ + fun run(implicitStateValue: Any?) +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenNode.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenNode.kt index 965fa68..1e0ebeb 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenNode.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenNode.kt @@ -1,6 +1,22 @@ package com.zup.nimbus.core.tree -interface ServerDrivenNode { +import com.zup.nimbus.core.dependency.Dependency +import com.zup.nimbus.core.dependency.Dependent +import com.zup.nimbus.core.scope.Scope + +/** + * Represents a component (node) of the original json. + * + * The components in a json are represented as follows (Typescript): + * interface Component { + * "_:component": string, // the component identifier, equivalent to "component" in this class + * state: State, // equivalent to a ServerDrivenState, a property of Scope + * id: string, // the identifier for this component + * properties?: Record, // the properties of the component + * children?: Component[], // the children of this component + * } + */ +interface ServerDrivenNode: Dependency, Dependent, Scope { /** * The unique id for this component. */ @@ -18,9 +34,19 @@ interface ServerDrivenNode { * The children of this node. If this is a leaf-node, children will be null or an empty map. */ val children: List? - /** - * Whether this node needs to be updated or not. If the UI layer decides to use this, it must set it to false once - * the node is rendered. - */ - var dirty: Boolean +} + +/** + * Traverses the tree using a depth-first-search algorithm. As soon as it finds a node with the specified id, it returns + * the node. + * + * If a node with the specified id is not found, null is returned. + */ +fun ServerDrivenNode.findNodeById(id: String): ServerDrivenNode? { + if (this.id == id) return this + this.children?.forEach { + val found = it.findNodeById(id) + if (found != null) return found + } + return null } diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenState.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenState.kt deleted file mode 100644 index 1205fb1..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/tree/ServerDrivenState.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.zup.nimbus.core.tree - -import com.zup.nimbus.core.utils.deepCopy -import com.zup.nimbus.core.utils.setMapValue - -open class ServerDrivenState( - /** - * The id of the state. - */ - val id: String, - /** - * The value of the state. Do not use this value as settable. - * @see set, to set the new value of this state use the `set` function. - */ - internal var value: Any?, - /** - * The node that declared this state. This must be null if the state has no parent. - */ - val parent: RenderNode?, -) { - /** - * Gets the current value of this state. Do not use this value as settable. - * - * @see set, to set the new value of this state use the `set` function. - * @return the current value of this state. - */ - fun get(): Any? { - return value - } - - /** - * Changes the value of this state at the provided path. - * - * @param newValue the new value of "state.value.$path". Must be encodable, i.e. null, string, number, boolean, - * Map or List. - * @param path the path within the state to modify. Example: "" to alter the entire state. "foo.bar" to alter the - * property "bar" of "foo" in the map "state.value". If "path" is not empty and "state.value" is not a mutable map, it - * is converted to one. The path must only contain letters, numbers and underscores separated by dots. - */ - open fun set(newValue: Any?, path: String) { - if (path.isEmpty()) { - value = newValue - } else { - if (value !is MutableMap<*, *>) value = HashMap() - setMapValue(value as MutableMap, path, newValue) - } - } -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/TreeUpdateMode.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/TreeUpdateMode.kt deleted file mode 100644 index 3805a3b..0000000 --- a/src/commonMain/kotlin/com/zup/nimbus/core/tree/TreeUpdateMode.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.zup.nimbus.core.tree - -enum class TreeUpdateMode { - /** - * Adds the new node before the current set of children, i.e. at the first position of the array. - */ - Prepend, - /** - * Adds the new node after the current set of children, i.e. at the last position of the array. - */ - Append, - /** - * Replaces the current set of children by the new node. - */ - Replace, - /** - * Replaces the node itself, not its children, by the new node. - */ - ReplaceItself, -} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/DynamicAction.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/DynamicAction.kt new file mode 100644 index 0000000..f9f5661 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/DynamicAction.kt @@ -0,0 +1,62 @@ +package com.zup.nimbus.core.tree.dynamic + +import com.zup.nimbus.core.ActionHandler +import com.zup.nimbus.core.ActionInitializationHandler +import com.zup.nimbus.core.ActionInitializedEvent +import com.zup.nimbus.core.scope.CloneAfterInitializationError +import com.zup.nimbus.core.scope.DoubleInitializationError +import com.zup.nimbus.core.scope.LazilyScoped +import com.zup.nimbus.core.scope.Scope +import com.zup.nimbus.core.tree.ServerDrivenAction +import com.zup.nimbus.core.tree.ServerDrivenEvent +import com.zup.nimbus.core.tree.dynamic.container.PropertyContainer + +/** + * DynamicActions are a type of ServerDrivenAction that can change its properties during its lifecycle. These changes + * are made according to expressions and states in the current tree. + */ +class DynamicAction( + override val name: String, + override val handler: ActionHandler, + /** + * The function to run once the action is initialized. + */ + private val initHandler: ActionInitializationHandler?, +) : ServerDrivenAction, LazilyScoped { + override var properties: Map? = null + override var metadata: Map? = null + /** + * A container that knows how to update the dynamic properties of this action. + */ + internal var propertyContainer: PropertyContainer? = null + /** + * A container that knows how to update the dynamic metadata of this action. + */ + internal var metadataContainer: PropertyContainer? = null + private var hasInitialized = false + + override fun update() { + properties = propertyContainer?.read() + metadata = metadataContainer?.read() + } + + override fun initialize(scope: Scope) { + if (scope !is ServerDrivenEvent) throw IllegalArgumentException("Actions must be initialized with events!") + if (hasInitialized) throw DoubleInitializationError() + propertyContainer?.initialize(scope) + metadataContainer?.initialize(scope) + propertyContainer?.addDependent(this) + metadataContainer?.addDependent(this) + update() + initHandler?.let { it(ActionInitializedEvent(this, scope)) } + hasInitialized = true + } + + override fun clone(): DynamicAction { + if (hasInitialized) throw CloneAfterInitializationError() + val cloned = DynamicAction(name, handler, initHandler) + cloned.metadataContainer = metadataContainer?.clone() + cloned.propertyContainer = propertyContainer?.clone() + return cloned + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/DynamicEvent.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/DynamicEvent.kt new file mode 100644 index 0000000..01a4762 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/DynamicEvent.kt @@ -0,0 +1,61 @@ +package com.zup.nimbus.core.tree.dynamic + +import com.zup.nimbus.core.dependency.CommonDependency +import com.zup.nimbus.core.ActionTriggeredEvent +import com.zup.nimbus.core.scope.CloneAfterInitializationError +import com.zup.nimbus.core.scope.DoubleInitializationError +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.ServerDrivenState +import com.zup.nimbus.core.ServerDrivenView +import com.zup.nimbus.core.dependency.DependencyUpdateManager +import com.zup.nimbus.core.scope.CommonScope +import com.zup.nimbus.core.scope.LazilyScoped +import com.zup.nimbus.core.scope.Scope +import com.zup.nimbus.core.scope.closestScopeWithType +import com.zup.nimbus.core.tree.ServerDrivenEvent +import com.zup.nimbus.core.tree.ServerDrivenNode + +/** + * DynamicEvents are a type of ServerDrivenEvent that can run DynamicActions. + */ +@Suppress("UseCheckOrError") +class DynamicEvent( + override val name: String, +): ServerDrivenEvent, LazilyScoped, CommonScope(listOf(ServerDrivenState(name, null))) { + override lateinit var actions: List + + override val node: ServerDrivenNode by lazy { + closestScopeWithType() ?: throw IllegalStateException("This event is not linked to a node!") + } + override val view: ServerDrivenView by lazy { + closestScopeWithType() ?: throw IllegalStateException("This event is not linked to a view!") + } + override val nimbus: Nimbus by lazy { + closestScopeWithType() ?: throw IllegalStateException("This event is not linked to a nimbus instance!") + } + + override fun run() { + val dependencies = mutableSetOf() + actions.forEach { it.handler(ActionTriggeredEvent(action = it, dependencies = dependencies, scope = this)) } + DependencyUpdateManager.updateDependentsOf(dependencies) + } + + override fun run(implicitStateValue: Any?) { + states!!.first().set(implicitStateValue) + DependencyUpdateManager.updateDependentsOf(states.toSet()) + run() + } + + override fun initialize(scope: Scope) { + if (parent != null) throw DoubleInitializationError() + parent = scope + actions.forEach { it.initialize(this) } + } + + override fun clone(): DynamicEvent { + if (parent != null) throw CloneAfterInitializationError() + val cloned = DynamicEvent(name) + cloned.actions = actions.map { it.clone() } + return cloned + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/builder/ActionBuilder.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/builder/ActionBuilder.kt new file mode 100644 index 0000000..b808927 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/builder/ActionBuilder.kt @@ -0,0 +1,59 @@ +package com.zup.nimbus.core.tree.dynamic.builder + +import com.zup.nimbus.core.ActionHandler +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.tree.dynamic.DynamicAction +import com.zup.nimbus.core.tree.dynamic.container.PropertyContainer +import com.zup.nimbus.core.utils.valueOfKey + +/** + * Builds DynamicActions from JSON sources. + */ +class ActionBuilder(private val nimbus: Nimbus) { + /** + * Verifies if this JSON structure represents an action. + */ + fun isJsonAction(maybeAction: Any?): Boolean { + return maybeAction is Map<*, *> && maybeAction.containsKey("_:action") + } + + private fun actionNotFoundError(name: String): ActionHandler { + val error = "Couldn't find handler for action with name \"$name\". Please, make sure you registered your " + + "custom actions." + nimbus.logger.error(error) + return { nimbus.logger.error(error) } + } + + private fun createHandler(name: String): ActionHandler { + val executionHandler = nimbus.uiLibraryManager.getAction(name) ?: return actionNotFoundError(name) + val observers = nimbus.uiLibraryManager.getActionObservers() + return { event -> + executionHandler(event) + observers.forEach { it(event) } + } + } + + /** + * Builds a DynamicAction from its json map representation. + * + * To avoid errors, verify if `map` is an action (isJsonAction) before calling this method. + */ + fun buildFromJsonMap(map: Map): DynamicAction { + val name: String = valueOfKey(map, "_:action") + val handler = createHandler(name) + val initHandler = nimbus.uiLibraryManager.getActionInitializer(name) + val properties: Map? = valueOfKey(map, "properties") + val metadata: Map? = valueOfKey(map, "metadata") + val action = DynamicAction(name, handler, initHandler) + + action.propertyContainer = properties?.let { + PropertyContainer(it, nimbus) + } + + action.metadataContainer = metadata?.let { + PropertyContainer(it, nimbus) + } + + return action + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/builder/EventBuilder.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/builder/EventBuilder.kt new file mode 100644 index 0000000..c08c07e --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/builder/EventBuilder.kt @@ -0,0 +1,36 @@ +package com.zup.nimbus.core.tree.dynamic.builder + +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.tree.dynamic.DynamicEvent +import com.zup.nimbus.core.utils.UnexpectedDataTypeError + +/** + * Builds DynamicEvents from JSON sources. + */ +class EventBuilder(nimbus: Nimbus) { + private val actionBuilder = ActionBuilder(nimbus) + + /** + * Verifies if this JSON structure represents an event. An event is a list of actions. + */ + fun isJsonEvent(maybeEvent: Any?): Boolean { + return maybeEvent is List<*> && maybeEvent.isNotEmpty() && actionBuilder.isJsonAction(maybeEvent.first()!!) + } + + /** + * Builds a DynamicEvent from its json map representation. + * + * To avoid errors, verify if `map` is an event (isJsonEvent) before calling this method. + */ + fun buildFromJsonMap(name: String, jsonEvent: Any?): DynamicEvent { + try { + @Suppress("UNCHECKED_CAST") + jsonEvent as List> + val event = DynamicEvent(name) + event.actions = jsonEvent.map { actionBuilder.buildFromJsonMap(it) } + return event + } catch (e: UnexpectedDataTypeError) { + throw MalformedActionListError(e.message) + } + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/builder/NodeBuilder.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/builder/NodeBuilder.kt new file mode 100644 index 0000000..05bb3fa --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/builder/NodeBuilder.kt @@ -0,0 +1,92 @@ +package com.zup.nimbus.core.tree.dynamic.builder + +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.ServerDrivenState +import com.zup.nimbus.core.tree.dynamic.container.NodeContainer +import com.zup.nimbus.core.tree.dynamic.container.PropertyContainer +import com.zup.nimbus.core.tree.dynamic.node.DynamicNode +import com.zup.nimbus.core.tree.dynamic.node.ForEachNode +import com.zup.nimbus.core.tree.dynamic.node.IfNode +import com.zup.nimbus.core.tree.dynamic.node.RootNode +import com.zup.nimbus.core.utils.UnexpectedDataTypeError +import com.zup.nimbus.core.utils.transformJsonObjectToMap +import com.zup.nimbus.core.utils.valueOfKey +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.decodeFromString + +/** + * Builds DynamicNodes from JSON sources. + */ +class NodeBuilder(private val nimbus: Nimbus) { + private fun buildNode(jsonNode: Map, jsonPath: String): DynamicNode { + val originalId: String? = valueOfKey(jsonNode, "id") + try { + val id = originalId ?: nimbus.idManager.next() + val component: String = valueOfKey(jsonNode, "_:component") + val stateMap: Map? = valueOfKey(jsonNode, "state") + val states = stateMap?.let { + val stateId: String = valueOfKey(stateMap, "id") + val stateValue: Any? = valueOfKey(stateMap, "value") + listOf(ServerDrivenState(stateId, stateValue)) + } + val properties: Map? = valueOfKey(jsonNode, "properties") + val children: List>? = valueOfKey(jsonNode, "children") + + val node = when(component) { + "if" -> IfNode(id, states) + "forEach" -> ForEachNode(id, states) + else -> DynamicNode(id, component, states) + } + + node.propertyContainer = properties?.let { + PropertyContainer(properties, nimbus) + } + + node.childrenContainer = children?.let { + val childrenAsNodes = children.mapIndexed { index, item -> + buildNode(item, "$jsonPath.children[:$index]") + } + NodeContainer(childrenAsNodes) + } + + return node + } catch (e: UnexpectedDataTypeError) { + throw MalformedComponentError(originalId, jsonPath, e.message) + } + } + + /** + * Builds a DynamicNode tree from its string representation (json). + * + * This method is recursive, i.e. it will transform every Json Node into its DynamicNode representation. After + * processing every node, the tree will be encapsulated in a RootNode. + * + * Attention: the returned node must be initialized before rendered. + * + * @throws MalformedJsonError if the string is not a valid json. + * @throws MalformedComponentError when a component node contains unexpected data. + */ + @Throws(MalformedJsonError::class, MalformedComponentError::class) + fun buildFromJsonString(json: String): RootNode { + val jsonObject: JsonObject + try { + jsonObject = Json.decodeFromString(json) + } catch (e: Throwable) { + throw MalformedJsonError("The string provided is not a valid json.") + } + return buildFromJsonMap(transformJsonObjectToMap(jsonObject)) + } + + /** + * Same as buildFromJsonString, but accepts the parsed Json map instead. + * + * @throws MalformedComponentError when a component node contains unexpected data. + */ + @Throws(MalformedComponentError::class) + fun buildFromJsonMap(jsonMap: Map): RootNode { + val root = RootNode() + root.childrenContainer = NodeContainer(listOf(buildNode(jsonMap, "$"))) + return root + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/error.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/builder/error.kt similarity index 92% rename from src/commonMain/kotlin/com/zup/nimbus/core/tree/error.kt rename to src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/builder/error.kt index e886b0e..2d8a798 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/tree/error.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/builder/error.kt @@ -1,4 +1,4 @@ -package com.zup.nimbus.core.tree +package com.zup.nimbus.core.tree.dynamic.builder open class MalformedJsonError(override val message: String): Error("$message Please check your json string.") diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/container/NodeContainer.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/container/NodeContainer.kt new file mode 100644 index 0000000..64325ea --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/container/NodeContainer.kt @@ -0,0 +1,75 @@ +package com.zup.nimbus.core.tree.dynamic.container + +import com.zup.nimbus.core.scope.CloneAfterInitializationError +import com.zup.nimbus.core.scope.DoubleInitializationError +import com.zup.nimbus.core.scope.LazilyScoped +import com.zup.nimbus.core.dependency.CommonDependency +import com.zup.nimbus.core.dependency.Dependent +import com.zup.nimbus.core.scope.Scope +import com.zup.nimbus.core.tree.dynamic.node.DynamicNode + +/** + * Manages a dynamic collection of nodes, updating the values returned by `read()` whenever they update. A DynamicNode + * can update if it's polymorphic like ForEach and If. + */ +class NodeContainer( + private val nodeList: List, +): Dependent, CommonDependency(), LazilyScoped { + private var uiNodes = emptyList() + private var hasInitialized = false + + override fun initialize(scope: Scope) { + if (hasInitialized) throw DoubleInitializationError() + nodeList.forEach { + it.initialize(scope) + if (it.polymorphic) it.addDependent(this) + } + hasInitialized = true + update() + hasChanged = false + } + + /** + * Returns the current set of UI Nodes that should be rendered by the UI Layer. This excludes any polymorphic node. + * An If node for instance (polymorphic), is replaced by its result (the contents of Then or Else). + */ + fun read(): List { + return uiNodes + } + + /** + * Recursively skips a polymorphic node getting its children instead. + */ + private fun extractUIFromPolymorphicNode(node: DynamicNode): List { + val result = mutableListOf() + node.children?.forEach { + if (it.polymorphic) result.addAll(extractUIFromPolymorphicNode(it)) + else result.add(it) + } + return result + } + + private fun extractUIFromNode(node: DynamicNode): List { + return if (node.polymorphic) extractUIFromPolymorphicNode(node) else listOf(node) + } + + override fun update() { + val result = mutableListOf() + nodeList.forEach { result.addAll(extractUIFromNode(it)) } + if (result.map { it.id } != uiNodes.map { it.id }) { + uiNodes = result + hasChanged = true + } + } + + /** + * Clones this node container adding a suffix to the id of each node contained. + */ + fun clone(idSuffix: String): NodeContainer { + if (hasInitialized) throw CloneAfterInitializationError() + val clonedNodeList = nodeList.map { it.clone(idSuffix) } + return NodeContainer(clonedNodeList) + } + + override fun clone(): NodeContainer = clone("") +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/container/PropertyContainer.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/container/PropertyContainer.kt new file mode 100644 index 0000000..23bbc75 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/container/PropertyContainer.kt @@ -0,0 +1,160 @@ +package com.zup.nimbus.core.tree.dynamic.container + +import com.zup.nimbus.core.scope.CloneAfterInitializationError +import com.zup.nimbus.core.scope.DoubleInitializationError +import com.zup.nimbus.core.scope.LazilyScoped +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.expression.Expression +import com.zup.nimbus.core.dependency.CommonDependency +import com.zup.nimbus.core.dependency.Dependent +import com.zup.nimbus.core.scope.Scope +import com.zup.nimbus.core.tree.dynamic.DynamicEvent + +/** + * Manages a dynamic property map, where a value can change depending on the current evaluation of an expression. + */ +class PropertyContainer private constructor( + private val nimbus: Nimbus, +): LazilyScoped, CommonDependency(), Dependent { + // General variables + + /** + * Functions that must be called to update the current map of properties. Each function in this list updates a + * specific key in the map according to its original expression. + */ + private var expressionEvaluators = mutableListOf<() -> Unit>() + /** + * The current processed map of properties. This map must never contain an expression, since it will be received by + * the UI Layer. + */ + private lateinit var currentProperties: Map + private var hasInitialized = false + + // Variables that should be freed upon initialization + + private var expressions: MutableList? = mutableListOf() + private var events: MutableList? = mutableListOf() + + // Constructors + + constructor(properties: Map, nimbus: Nimbus): this(nimbus) { + currentProperties = parseMap(properties) + } + + private constructor( + currentProperties: Map, + expressions: MutableList, + expressionEvaluators: MutableList<() -> Unit>, + events: MutableList, + nimbus: Nimbus, + ): this(nimbus) { + this.currentProperties = currentProperties + this.expressions = expressions + this.expressionEvaluators = expressionEvaluators + this.events = events + } + + // Lazy initialization and cloning + + override fun initialize(scope: Scope) { + if (hasInitialized) throw DoubleInitializationError() + expressions?.forEach { + if (it is LazilyScoped<*>) it.initialize(scope) + if (it is CommonDependency) it.dependents.add(this) + } + events?.forEach { it.initialize(scope) } + expressions = null + events = null + hasInitialized = true + update() + hasChanged = false + } + + override fun clone(): PropertyContainer { + if (hasInitialized) throw CloneAfterInitializationError() + val clonedExpressions = mutableListOf() + val clonedEvents = mutableListOf() + val clonedExpressionEvaluators = mutableListOf<() -> Unit>() + val clonedProperties = PropertyCopying.copyMap( + source = currentProperties, + clonedExpressions, + clonedExpressionEvaluators, + clonedEvents, + ) + return PropertyContainer(clonedProperties, clonedExpressions, clonedExpressionEvaluators, clonedEvents, nimbus) + } + + // Other methods + + /** + * Parses any value in the original property map. + */ + private fun parseAny(toParse: Any?, key: String? = null): Any? { + return when(toParse) { + is String -> { + if (nimbus.expressionParser.containsExpression(toParse)) { + val expression = nimbus.expressionParser.parseString(toParse) + expressions?.add(expression) + expression + } + else toParse + } + is List<*> -> { + if (key != null && nimbus.eventBuilder.isJsonEvent(toParse)) { + val event = nimbus.eventBuilder.buildFromJsonMap(key, toParse) + events?.add(event) + event + } else { + parseList(toParse) + } + } + is Map<*, *> -> @Suppress("UNCHECKED_CAST") parseMap(toParse as Map) + else -> toParse + } + } + + /** + * Parses a list in the original property map. + */ + private fun parseList(toParse: List): List { + val result = mutableListOf() + toParse.forEachIndexed { index, value -> + val parsed = parseAny(value) + if (parsed is Expression) { + expressionEvaluators.add { result[index] = parsed.getValue() } + } + result.add(index, parsed) + } + return result + } + + /** + * Parses a map in the original property map. + */ + private fun parseMap(toParse: Map): HashMap { + val result = HashMap() + toParse.forEach { + val key = it.key + val value = it.value + val parsed = parseAny(value, key) + if (parsed is Expression) { + expressionEvaluators.add { result[key] = parsed.getValue() } + } + result[key] = parsed + } + return result + } + + /** + * Returns the current map of properties with each value updated according to the most recent result of its expression + * (if it was originally an expression). + */ + fun read(): Map { + return currentProperties + } + + override fun update() { + expressionEvaluators.forEach { it() } + hasChanged = true + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/container/PropertyCopying.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/container/PropertyCopying.kt new file mode 100644 index 0000000..b138962 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/container/PropertyCopying.kt @@ -0,0 +1,89 @@ +package com.zup.nimbus.core.tree.dynamic.container + +import com.zup.nimbus.core.scope.LazilyScoped +import com.zup.nimbus.core.expression.Expression +import com.zup.nimbus.core.expression.Literal +import com.zup.nimbus.core.tree.dynamic.DynamicEvent + +/** + * This is a helper for the PropertyContainer class. Its objective is to help cloning the parsed properties into a new + * PropertyContainer. + */ +internal object PropertyCopying { + fun copyMap( + source: Map, + expressions: MutableList, + expressionEvaluators: MutableList<() -> Unit>, + events: MutableList, + ): MutableMap { + val result = mutableMapOf() + source.forEach { entry -> + result[entry.key] = copyAny( + source = entry.value, + setter = { result[entry.key] = it }, + expressions, + expressionEvaluators, + events, + ) + } + return result + } + + private fun copyList( + source: List, + expressions: MutableList, + expressionEvaluators: MutableList<() -> Unit>, + events: MutableList, + ): MutableList { + val result = mutableListOf() + source.forEachIndexed { index, value -> + result.add( + copyAny( + source = value, + setter = { result[index] = it }, + expressions, + expressionEvaluators, + events, + ) + ) + } + return result + } + + private fun copyAny( + source: Any?, + setter: (value: Any?) -> Unit, + expressions: MutableList, + expressionEvaluators: MutableList<() -> Unit>, + events: MutableList, + ): Any? { + return when(source) { + is String, is Number, is Boolean, is Literal -> source + is List<*> -> copyList(source, expressions, expressionEvaluators, events) + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + (copyMap( + source as Map, + expressions, + expressionEvaluators, + events + )) + } + is Expression -> { + val clonedExpression = if (source is LazilyScoped<*>) source.clone() as Expression else source + expressions.add(clonedExpression) + expressionEvaluators.add { setter(clonedExpression.getValue()) } + clonedExpression + } + is DynamicEvent -> { + val clonedEvent = source.clone() + events.add(clonedEvent) + clonedEvent + } + null -> null + else -> throw IllegalArgumentException( + "Unsupported value type while trying to copy property map: ${source::class.qualifiedName}" + ) + } + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/DynamicNode.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/DynamicNode.kt new file mode 100644 index 0000000..562d5bd --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/DynamicNode.kt @@ -0,0 +1,81 @@ +package com.zup.nimbus.core.tree.dynamic.node + +import com.zup.nimbus.core.scope.CloneAfterInitializationError +import com.zup.nimbus.core.scope.DoubleInitializationError +import com.zup.nimbus.core.ServerDrivenState +import com.zup.nimbus.core.dependency.CommonDependency +import com.zup.nimbus.core.scope.CommonScope +import com.zup.nimbus.core.scope.LazilyScoped +import com.zup.nimbus.core.scope.Scope +import com.zup.nimbus.core.tree.ServerDrivenNode +import com.zup.nimbus.core.tree.dynamic.container.NodeContainer +import com.zup.nimbus.core.tree.dynamic.container.PropertyContainer + +/** + * DynamicNodes are a type of ServerDrivenNode that can change its properties and children during its lifecycle. These + * changes are made according to expressions and states in the current tree. + */ +open class DynamicNode( + override val id: String, + override val component: String, + states: List?, + /** + * A DynamicNode is said to be polymorphic if it can turn itself into another node in respect to the final UI tree + * (rendered by the UI layer). Polymorphic nodes can be invisible at a time, they can also be a single node or + * multiple nodes. They can at a time be a button and at another be a text, for instance. + * + * Polymorphic nodes exist in the data structure, but how they appear in the tree read by the UI Layer depends on + * their own logic. Examples of Polymorphic nodes are: If and ForEach, which calculate their children based on the + * current value of its properties. Since a NodeContainer skips every polymorphic node by only getting its children, + * they never appear in the list of children of a DynamicNode. + * + * For now, we don't intend to make polymorphic nodes an open feature and this boolean works. If this changes, we'll + * probably need PolymorphicNode to be a subclass of DynamicNode. + */ + val polymorphic: Boolean = false, +): CommonDependency(), Scope by CommonScope(states), LazilyScoped, ServerDrivenNode { + override var properties: Map? = null + override var children: List? = null + /** + * A container that knows how to update the dynamic properties of this node. + */ + internal var propertyContainer: PropertyContainer? = null + /** + * A container that knows how to update the dynamic children of this node. + */ + internal var childrenContainer: NodeContainer? = null + + override fun update() { + propertyContainer?.let { properties = it.read() } + childrenContainer?.let { children = it.read() } + hasChanged = true + } + + override fun initialize(scope: Scope) { + if (parent != null) throw DoubleInitializationError() + parent = scope + propertyContainer?.initialize(this) + childrenContainer?.initialize(this) + propertyContainer?.addDependent(this) + childrenContainer?.addDependent(this) + update() + hasChanged = false + } + + protected fun clone(idSuffix: String, builder: (String, List?) -> DynamicNode): DynamicNode { + if (parent != null) throw CloneAfterInitializationError() + val cloned = builder("$id$idSuffix", states?.map { it.clone() }) + cloned.propertyContainer = propertyContainer?.clone() + cloned.childrenContainer = childrenContainer?.clone(idSuffix) + return cloned + } + + /** + * Copies this DynamicNode altering only its id. The id is suffixed with the String passed as parameter. + */ + open fun clone(idSuffix: String): DynamicNode = clone(idSuffix) { id, states -> + DynamicNode(id, component, states) + } + + override fun clone(): DynamicNode = clone("") +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/ForEachNode.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/ForEachNode.kt new file mode 100644 index 0000000..52c9eda --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/ForEachNode.kt @@ -0,0 +1,146 @@ +package com.zup.nimbus.core.tree.dynamic.node + +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.ServerDrivenState +import com.zup.nimbus.core.scope.Scope +import com.zup.nimbus.core.scope.StateOnlyScope +import com.zup.nimbus.core.scope.closestScopeWithType +import com.zup.nimbus.core.scope.getPathToScope +import com.zup.nimbus.core.tree.dynamic.container.NodeContainer +import com.zup.nimbus.core.utils.valueOfKey + +/** + * A rendered list that can change must somehow identify each of its item. This encapsulates an item with an object + * that has an id and is comparable (equals). Ideally, the user will pass a key to allow us to identify the unique + * property within an item. If this doesn't happen, we'll use the index of the item in the list. + */ +private class IdentifiableItem(val value: Any?, index: Int, key: String?) { + val id: String + + init { + // fixme: we probably want to change this to valueOfPath, but it would be computationally more expensive + val keyValue: Any? = key?.let { valueOfKey(value, it) } + id = if (keyValue == null) "$index" else "$keyValue" + } + + override fun equals(other: Any?): Boolean { + return other is IdentifiableItem && id == other.id + } + + override fun hashCode(): Int { + var result = value?.hashCode() ?: 0 + result = 31 * result + id.hashCode() + return result + } +} + +// fixme: (1) just like the if component, we have the problem here of not freeing unused nodes. This can be interesting +// because nodes that leave the list will be the same if they come back, preserving their state. On the other hand, +// we're not freeing up the memory and these nodes will be deallocated only when the root node is. We need to decide if +// this is a feature or a bug, after this, this comment can be removed. +// +// fixme: (2) when an item state is updated via setState, the list won't trigger updates to entities that depend on it. +// A fix for this would be to make the state referred by the property "items" dependent on each item state created by +// the forEach. This has a cost though, do we want to add this cost to every forEach? Do we want to control this +// behavior via a property? +// +// fixme: (3) if an item of the array (items) is directly set via setState, the item will not update. +// Example: setState(state = myDataSet, path = "[2]", value = "new value"); this won't trigger an update. +// A possible fix would be to make each item state depend on the array. The problem is, by implementing this fix and +// the fix to the previous issue (2), we create lots of cyclic dependencies, which will end up in an infinity loop +// when processed by the function updateDependents. +// +// fixme: (4) when a child is moved in the dataset, the index state doesn't update. +/** + * ForEachNode is a polymorphic DynamicNode that iterates over a data set (items) and generate some UI for each of its + * items. The template used for each iteration is the children in the original json. + * + * Reminder: a polymorphic node is a special type of dynamic node that is always skipped by the NodeContainer when + * calculating the children of a node. Only the non-polymorphic children of a polymorphic node ends up in the UI tree. + * To know more about polymorphic nodes, read the documentation for "polymorphic" in "DynamicNode". + * + * A ForEach node in its json form is represented by the following type definition (Typescript): + * interface ForEach { + * items?: any[], // the data set to iterate over + * iteratorName?: string, // the state id to use for the current item; default: item + * indexName?: string, // the state id to use for the current index; default: index + * key?: string, // a property key to identify each item in the data set + * children: Component[], // the template + * } + */ +class ForEachNode( + id: String, + states: List?, +) : DynamicNode(id, "forEach", states, true) { + /** + * We can't recreate the entire subtree everytime an item is added or removed. For this reason, we save every node + * upon its creation and just recover it when updating the content of the ForEach. + */ + private val nodeStorage = mutableMapOf() + private var iteratorName: String = "item" + private var indexName: String = "index" + private var key: String? = null + private var hasInitialized = false + private var items: List = emptyList() + private val nimbus: Nimbus? by lazy { closestScopeWithType() } + + override fun initialize(scope: Scope) { + parent = scope + propertyContainer?.initialize(this) + propertyContainer?.addDependent(this) + properties = propertyContainer?.read() + iteratorName = valueOfKey(properties, "iteratorName") ?: iteratorName + indexName = valueOfKey(properties, "indexName") ?: indexName + key = valueOfKey(properties, "key") + hasInitialized = true + update() + hasChanged = false + } + + private fun buildChild( + index: Int, + item: IdentifiableItem, + template: NodeContainer, + ): NodeContainer { + val itemState = ServerDrivenState(iteratorName, item.value) + val indexState = ServerDrivenState(indexName, index) + val itemScope = StateOnlyScope(this, listOf(itemState, indexState)) + val child = template.clone(":${item.id}") + nodeStorage[item.id] = child + child.initialize(itemScope) + child.addDependent(this) + return child + } + + private fun calculateChildren(): List? { + return childrenContainer?.let { childrenContainer -> + val containers = items.mapIndexed { index, item -> + nodeStorage[item.id] ?: buildChild(index, item, childrenContainer) + } + containers.map { it.read() }.flatten() + } + } + + private fun warnIfUpdatingWithoutKey() { + if (items.isNotEmpty() && key?.isEmpty() != false) { + nimbus?.logger?.warn("You're trying to modify a forEach component after its initialization without providing " + + "a key for its elements. This can cause undesirable behavior and performance issues. Please, provide " + + "the property \"key\" for the forEach component at:\n${this.getPathToScope()}.") + } + } + + override fun update() { + properties = propertyContainer?.read() + val newItems: List = valueOfKey(properties, "items") ?: emptyList() + val newIdentified = newItems.mapIndexed { index, item -> IdentifiableItem(item, index, key) } + if (newIdentified != items) { + warnIfUpdatingWithoutKey() + items = newIdentified + val newChildren = calculateChildren() + hasChanged = true + children = newChildren + } + } + + override fun clone(idSuffix: String): DynamicNode = clone(idSuffix) { id, states -> ForEachNode(id, states) } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/IfNode.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/IfNode.kt new file mode 100644 index 0000000..3e7de11 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/IfNode.kt @@ -0,0 +1,48 @@ +package com.zup.nimbus.core.tree.dynamic.node + +import com.zup.nimbus.core.ServerDrivenState +import com.zup.nimbus.core.utils.valueOfKey + +// fixme: normally, in UI frameworks, if-else blocks completely remove the other branch from the tree and rebuilds it +// when the condition changes. This is not being done here. On the positive side, states will never be lost when +// switching from true to false. On the other hand, we won't free up the memory for the if-else branch not currently +// rendered until the associated RootNode is unmounted. If we decide this to be a feature and not a bug, remove this +// commentary. +/** + * IfNode is a polymorphic DynamicNode that chooses only one of its original subtree (json) to render at a time. The + * chosen subtree depends on the value of the property "condition". When "condition" is true and "Then" is a child of + * "If", the children of "Then" is rendered. When "condition" is false and "Else" is a child of "If", the children of + * "Else" is rendered. + * + * Reminder: a polymorphic node is a special type of dynamic node that is always skipped by the NodeContainer when + * calculating the children of a node. Only the non-polymorphic children of a polymorphic node ends up in the UI tree. + * To know more about polymorphic nodes, read the documentation for "polymorphic" in "DynamicNode". + * + * A, IfNode node in its json form is represented by the following type definition (Typescript): + * interface If { + * condition: boolean, + * children: Then | Else | [Then, Else], + * } + * interface Then { + * children: Component[], + * } + * interface Else { + * children: Component[], + * } + */ +class IfNode( + id: String, + states: List?, +) : DynamicNode(id, "if", states, true) { + override fun update() { + val condition: Boolean = valueOfKey(propertyContainer?.read(), "condition") + val fromContainer = childrenContainer?.read() + val thenNode = fromContainer?.find { it.component == "then" } + val elseNode = fromContainer?.find { it.component == "else" } + val previousChildrenStructure = children?.map { it.id } + children = if (condition) thenNode?.children else elseNode?.children + hasChanged = previousChildrenStructure != children?.map { it.id } + } + + override fun clone(idSuffix: String): DynamicNode = clone(idSuffix) { id, states -> IfNode(id, states) } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/RootNode.kt b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/RootNode.kt new file mode 100644 index 0000000..de13ca3 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/tree/dynamic/node/RootNode.kt @@ -0,0 +1,26 @@ +package com.zup.nimbus.core.tree.dynamic.node + +import com.zup.nimbus.core.ServerDrivenView + +/** + * A RootNode for a UI Tree. Used to wrap every tree yielded by a NodeBuilder. + * + * A RootNode is important to: + * 1. allow the use of polymorphic nodes in the root of the original json; + * 2. have an immutable reference to the current UI, even if its content is refreshed. + * + * A RootNode is rendered as a fragment. Fragment is the only UI component required by Nimbus Core to be implemented + * by the UI Layer. It must be a simple column aligned in the top left corner without any kind of styling. + * + * Root node always have the same id: "nimbus:root". + */ +class RootNode : DynamicNode("nimbus:root", "fragment", null, false) { + /** + * Replaces the current content of this root node and initializes it with the view passed as parameter. + * This is useful for performing a refresh operation on a Server Driven Screen. + */ + fun replaceContent(newContent: RootNode, view: ServerDrivenView) { + childrenContainer = newContent.childrenContainer + childrenContainer?.initialize(view) + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/types.kt b/src/commonMain/kotlin/com/zup/nimbus/core/types.kt new file mode 100644 index 0000000..69e9647 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/types.kt @@ -0,0 +1,5 @@ +package com.zup.nimbus.core + +typealias ActionHandler = (event: ActionTriggeredEvent) -> Unit +typealias ActionInitializationHandler = (event: ActionInitializedEvent) -> Unit +typealias OperationHandler = (arguments: List) -> Any? diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/ui/UILibrary.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ui/UILibrary.kt new file mode 100644 index 0000000..eec9bbb --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ui/UILibrary.kt @@ -0,0 +1,80 @@ +package com.zup.nimbus.core.ui + +import com.zup.nimbus.core.ActionHandler +import com.zup.nimbus.core.ActionInitializationHandler +import com.zup.nimbus.core.OperationHandler + +/** + * Represents the UI extensions that can be made by a third-party application. + * + * It's considered to be a UI extension every: + * - Action execution handler + * - Action initializer + * - Action observer + * - Operation + * + * This class must be extended in the UI layer to include components. + */ +open class UILibrary( + /** + * The namespace for this library. This string prefixes every action in the library with "${namespace}:". If namespace + * is an empty string, no prefix is added and this is considered to be an extension of the core UI library. + * + * Attention: this has no effect over operation names. + */ + val namespace: String = "", +) { + private val actions = mutableMapOf() + private val actionInitializers = mutableMapOf() + private val actionObservers = mutableListOf() + private val operations = mutableMapOf() + + fun addAction(name: String, handler: ActionHandler): UILibrary { + actions[name] = handler + return this + } + + fun addActionInitializer(name: String, handler: ActionInitializationHandler): UILibrary { + actionInitializers[name] = handler + return this + } + + fun addActionObserver(observer: ActionHandler): UILibrary { + actionObservers.add(observer) + return this + } + + fun addOperation(name: String, handler: OperationHandler): UILibrary { + operations[name] = handler + return this + } + + fun getAction(name: String): ActionHandler? { + return actions[name] + } + + fun getActionInitializer(name: String): ActionInitializationHandler? { + return actionInitializers[name] + } + + fun getActionObservers(): List { + return actionObservers + } + + fun getOperation(name: String): OperationHandler? { + return operations[name] + } + + /** + * Merges the given UILibrary into this UILibrary. This alters the current UILibrary. + * + * Attention: remember to override this method when extending this class. + */ + open fun merge(other: UILibrary): UILibrary { + actions.putAll(other.actions) + actionInitializers.putAll(other.actionInitializers) + actionObservers.addAll(other.actionObservers) + operations.putAll(other.operations) + return this + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/ui/UILibraryManager.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ui/UILibraryManager.kt new file mode 100644 index 0000000..4113b63 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ui/UILibraryManager.kt @@ -0,0 +1,76 @@ +package com.zup.nimbus.core.ui + +import com.zup.nimbus.core.ActionHandler +import com.zup.nimbus.core.ActionInitializationHandler +import com.zup.nimbus.core.OperationHandler +import com.zup.nimbus.core.regex.toFastRegex + +private val identifierRegex = "(?:(\\w+):)?(\\w+)".toFastRegex() + +/** + * Combines namespace and name in a single structure. + */ +class NamespaceName(val namespace: String, val name: String) { + operator fun component1(): String = namespace + operator fun component2(): String = name +} + +/** + * Manages the UILibraries. This class makes it easier to retrieve UI elements from all registered UILibraries. + */ +class UILibraryManager(coreLibrary: UILibrary, customLibraries: List? = null) { + private val libraries = mutableMapOf() + + companion object { + fun splitIdentifier(identifier: String): NamespaceName? { + val matched = identifierRegex.findWithGroups(identifier) ?: return null + val (namespace, name) = matched.destructured + return NamespaceName(namespace, name) + } + } + + init { + addLibrary(coreLibrary) + customLibraries?.forEach { addLibrary(it) } + } + + private fun get(identifier: String, getter: (UILibrary, String) -> T): T? { + val (namespace, name) = splitIdentifier(identifier) ?: return null + val library = libraries[namespace] + return library?.let { getter(it, name) } + } + + fun getAction(identifier: String): ActionHandler? { + return get(identifier) { lib, name -> + lib.getAction(name) + } + } + + fun getActionInitializer(identifier: String): ActionInitializationHandler? { + return get(identifier) { lib, name -> + lib.getActionInitializer(name) + } + } + + fun getActionObservers(): List { + return libraries.values.map { it.getActionObservers() }.flatten() + } + + fun getOperation(name: String): OperationHandler? { + libraries.forEach { + val operation = it.value.getOperation(name) + if (operation != null) return operation + } + return null + } + + fun getLibrary(namespace: String): UILibrary? { + return libraries[namespace] + } + + fun addLibrary(lib: UILibrary) { + val current = libraries[lib.namespace] + if (current == null) libraries[lib.namespace] = lib + else current.merge(lib) + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/ui/action/condition.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ui/action/condition.kt new file mode 100644 index 0000000..210df2c --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ui/action/condition.kt @@ -0,0 +1,19 @@ +package com.zup.nimbus.core.ui.action + +import com.zup.nimbus.core.ActionTriggeredEvent +import com.zup.nimbus.core.tree.ServerDrivenEvent +import com.zup.nimbus.core.utils.UnexpectedDataTypeError +import com.zup.nimbus.core.utils.valueOfKey + +internal fun condition(event: ActionTriggeredEvent) { + val properties = event.action.properties + try { + val condition: Boolean = valueOfKey(properties, "condition") + val onTrue: ServerDrivenEvent? = valueOfKey(properties, "onTrue") + val onFalse: ServerDrivenEvent? = valueOfKey(properties, "onFalse") + if (condition && onTrue != null) onTrue.run() + else if (!condition && onFalse != null) onFalse.run() + } catch (e: UnexpectedDataTypeError) { + event.scope.nimbus.logger.error("Error while executing conditional action.\n${e.message}") + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/action/log.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ui/action/log.kt similarity index 74% rename from src/commonMain/kotlin/com/zup/nimbus/core/action/log.kt rename to src/commonMain/kotlin/com/zup/nimbus/core/ui/action/log.kt index 4325bc0..79b5866 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/action/log.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ui/action/log.kt @@ -1,13 +1,13 @@ -package com.zup.nimbus.core.action +package com.zup.nimbus.core.ui.action import com.zup.nimbus.core.log.LogLevel -import com.zup.nimbus.core.render.ActionEvent +import com.zup.nimbus.core.ActionTriggeredEvent import com.zup.nimbus.core.utils.UnexpectedDataTypeError import com.zup.nimbus.core.utils.valueOfEnum import com.zup.nimbus.core.utils.valueOfKey -internal fun log(event: ActionEvent) { - val logger = event.view.nimbusInstance.logger +internal fun log(event: ActionTriggeredEvent) { + val logger = event.scope.nimbus.logger val properties = event.action.properties try { val message: String = valueOfKey(properties, "message") diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/ui/action/navigation.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ui/action/navigation.kt new file mode 100644 index 0000000..99549ba --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ui/action/navigation.kt @@ -0,0 +1,59 @@ +package com.zup.nimbus.core.ui.action + +import com.zup.nimbus.core.ActionEvent +import com.zup.nimbus.core.ActionInitializedEvent +import com.zup.nimbus.core.network.ServerDrivenHttpMethod +import com.zup.nimbus.core.network.ViewRequest +import com.zup.nimbus.core.ActionTriggeredEvent +import com.zup.nimbus.core.utils.UnexpectedDataTypeError +import com.zup.nimbus.core.utils.valueOfEnum +import com.zup.nimbus.core.utils.valueOfKey + +private inline fun getNavigator(event: ActionEvent) = event.scope.view.navigator + +private fun requestFromEvent(event: ActionEvent): ViewRequest { + val properties = event.action.properties + return ViewRequest( + url = valueOfKey(properties, "url"), + method = valueOfEnum(properties, "method", ServerDrivenHttpMethod.Get), + headers = valueOfKey(properties, "headers"), + fallback = valueOfKey(properties, "fallback"), + ) +} + +private fun pushOrPresent(event: ActionTriggeredEvent, isPush: Boolean) { + try { + val request = requestFromEvent(event) + if (isPush) getNavigator(event).push(request) + else getNavigator(event).present(request) + } catch (e: UnexpectedDataTypeError) { + event.scope.nimbus.logger.error("Error while navigating.\n${e.message}") + } +} + +internal fun push(event: ActionTriggeredEvent) = pushOrPresent(event, true) + +internal fun pop(event: ActionTriggeredEvent) = getNavigator(event).pop() + +internal fun popTo(event: ActionTriggeredEvent) { + try { + getNavigator(event).popTo(valueOfKey(event.action.properties, "url")) + } catch (e: UnexpectedDataTypeError) { + event.scope.nimbus.logger.error("Error while navigating.\n${e.message}") + } +} + +internal fun present(event: ActionTriggeredEvent) = pushOrPresent(event, false) + +internal fun dismiss(event: ActionTriggeredEvent) = getNavigator(event).dismiss() + +internal fun onPushOrPresentInitialized(event: ActionInitializedEvent) { + try { + val prefetch: Boolean = valueOfKey(event.action.properties, "prefetch") ?: false + if (!prefetch) return + val request = requestFromEvent(event) + event.scope.nimbus.viewClient.preFetch(request) + } catch (e: Throwable) { + event.scope.nimbus.logger.error("Error while pre-fetching view.\n${e.message}") + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/action/sendRequest.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ui/action/sendRequest.kt similarity index 80% rename from src/commonMain/kotlin/com/zup/nimbus/core/action/sendRequest.kt rename to src/commonMain/kotlin/com/zup/nimbus/core/ui/action/sendRequest.kt index 3954232..0641f16 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/action/sendRequest.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ui/action/sendRequest.kt @@ -1,9 +1,10 @@ -package com.zup.nimbus.core.action +package com.zup.nimbus.core.ui.action import com.zup.nimbus.core.network.FIRST_BAD_STATUS import com.zup.nimbus.core.network.ServerDrivenHttpMethod import com.zup.nimbus.core.network.ServerDrivenRequest -import com.zup.nimbus.core.render.ActionEvent +import com.zup.nimbus.core.ActionTriggeredEvent +import com.zup.nimbus.core.tree.ServerDrivenEvent import com.zup.nimbus.core.utils.transformJsonElementToKotlinType import com.zup.nimbus.core.utils.valueOfEnum import com.zup.nimbus.core.utils.valueOfKey @@ -16,18 +17,18 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement -internal fun sendRequest(event: ActionEvent) { - val nimbus = event.view.nimbusInstance +internal fun sendRequest(event: ActionTriggeredEvent) { val properties = event.action.properties + val nimbus = event.scope.nimbus try { // deserialize parameters val url: String = valueOfKey(properties, "url") val method: ServerDrivenHttpMethod = valueOfEnum(properties, "method", ServerDrivenHttpMethod.Get) val data: Any? = valueOfKey(properties, "data") val headers: Map? = valueOfKey(properties, "headers") - val onSuccess: ((successResponse: Map) -> Unit)? = valueOfKey(properties, "onSuccess") - val onError: ((errorResponse: Map) -> Unit)? = valueOfKey(properties, "onError") - val onFinish: ((_: Any?) -> Unit)? = valueOfKey(properties, "onFinish") + val onSuccess: ServerDrivenEvent? = valueOfKey(properties, "onSuccess") + val onError: ServerDrivenEvent? = valueOfKey(properties, "onError") + val onFinish: ServerDrivenEvent? = valueOfKey(properties, "onFinish") // create request and coroutine scope val request = ServerDrivenRequest( @@ -54,16 +55,16 @@ internal fun sendRequest(event: ActionEvent) { } // todo: verify if (response.status >= FIRST_BAD_STATUS) @Suppress("TooGenericExceptionThrown") throw Error(statusText) - if (onSuccess != null) onSuccess(callbackData) + onSuccess?.run(callbackData) } catch (e: Throwable) { nimbus.logger.error("Unable to send request.\n${e.message ?: ""}") - if (onError != null) onError(mapOf( + onError?.run(mapOf( "status" to 0, "statusText" to "Unable to send request", "message" to (e.message ?: "Unknown error."), )) } - if (onFinish != null) onFinish(null) + onFinish?.run() } } catch (e: Throwable) { nimbus.logger.error("Error while executing action \"sendRequest\".\n${e.message ?: ""}") diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/ui/action/setState.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ui/action/setState.kt new file mode 100644 index 0000000..0417e98 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ui/action/setState.kt @@ -0,0 +1,35 @@ +package com.zup.nimbus.core.ui.action + +import com.zup.nimbus.core.regex.toFastRegex +import com.zup.nimbus.core.ActionTriggeredEvent +import com.zup.nimbus.core.log.Logger +import com.zup.nimbus.core.scope.closestState +import com.zup.nimbus.core.scope.getPathToScope +import com.zup.nimbus.core.utils.UnexpectedDataTypeError +import com.zup.nimbus.core.utils.valueOfKey + +private val statePathRegex = """^(\w+)((?:\.\w+)*)${'$'}""".toFastRegex() + +internal fun setState(event: ActionTriggeredEvent) { + val properties = event.action.properties + val sourceEvent = event.scope + val logger: Logger by lazy { sourceEvent.nimbus.logger } + + try { + val path: String = valueOfKey(properties, "path") + val value: Any? = valueOfKey(properties, "value") + val matchResult = statePathRegex.findWithGroups(path) ?: + return logger.error("""The path "$path" is not a valid state path.""") + val (stateId, statePath) = matchResult.destructured + val state = sourceEvent.closestState(stateId) + if (state == null) { + val message = "Could not find state \"$stateId\" at:\n${sourceEvent.getPathToScope()}" + logger.error(message) + } else { + state.set(value, statePath, false) + event.dependencies.add(state) + } + } catch (e: UnexpectedDataTypeError) { + logger.error("Error while setting state.\n${e.message}") + } +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/ui/coreUILibrary.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ui/coreUILibrary.kt new file mode 100644 index 0000000..b17dbe8 --- /dev/null +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ui/coreUILibrary.kt @@ -0,0 +1,44 @@ +package com.zup.nimbus.core.ui + +import com.zup.nimbus.core.ui.action.condition +import com.zup.nimbus.core.ui.action.dismiss +import com.zup.nimbus.core.ui.action.log +import com.zup.nimbus.core.ui.action.onPushOrPresentInitialized +import com.zup.nimbus.core.ui.action.pop +import com.zup.nimbus.core.ui.action.popTo +import com.zup.nimbus.core.ui.action.present +import com.zup.nimbus.core.ui.action.push +import com.zup.nimbus.core.ui.action.sendRequest +import com.zup.nimbus.core.ui.action.setState +import com.zup.nimbus.core.ui.operations.registerArrayOperations +import com.zup.nimbus.core.ui.operations.registerLogicOperations +import com.zup.nimbus.core.ui.operations.registerNumberOperations +import com.zup.nimbus.core.ui.operations.registerOtherOperations +import com.zup.nimbus.core.ui.operations.registerStringOperations + +/** + * The action handlers, action initializers, action observers and operations of the core Nimbus library. + */ +val coreUILibrary = UILibrary("") + // Actions + .addAction("push") { push(it) } + .addAction("pop") { pop(it) } + .addAction("popTo") { popTo(it) } + .addAction("present") { present(it) } + .addAction("dismiss") { dismiss(it) } + .addAction("log") { log(it) } + .addAction("sendRequest") { sendRequest(it) } + .addAction("setState") { setState(it) } + .addAction("condition") { condition(it) } + // Action initializers + .addActionInitializer("push") { onPushOrPresentInitialized(it) } + .addActionInitializer("present") { onPushOrPresentInitialized(it) } + // operations + .run { + registerArrayOperations(this) + registerLogicOperations(this) + registerNumberOperations(this) + registerOtherOperations(this) + registerStringOperations(this) + this + } diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/operations/array.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ui/operations/array.kt similarity index 69% rename from src/commonMain/kotlin/com/zup/nimbus/core/operations/array.kt rename to src/commonMain/kotlin/com/zup/nimbus/core/ui/operations/array.kt index bab6d70..f2dac6b 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/operations/array.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ui/operations/array.kt @@ -1,27 +1,26 @@ -package com.zup.nimbus.core.operations +package com.zup.nimbus.core.ui.operations -import com.zup.nimbus.core.OperationHandler +import com.zup.nimbus.core.ui.UILibrary -internal fun getArrayOperations(): Map { - return mapOf( - "insert" to { +internal fun registerArrayOperations(library: UILibrary) { + library + .addOperation("insert") { val list = if (it[0] is List<*>) (it[0] as List<*>).toMutableList() else ArrayList() val item = it[1] val index = it.getOrNull(2) as Int? if (index == null) list.add(item) else list.add(index, item) list - }, - "remove" to { + } + .addOperation("remove") { val list = if (it[0] is List<*>) (it[0] as List<*>).toMutableList() else ArrayList() val item = it[1] list.remove(item) list - }, - "removeIndex" to { + } + .addOperation("removeIndex") { val list = if (it[0] is List<*>) (it[0] as List<*>).toMutableList() else ArrayList() val index = it.getOrNull(1) as Int? if (index == null) list.removeLast() else list.removeAt(index) list - }, - ) + } } diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/operations/logic.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ui/operations/logic.kt similarity index 58% rename from src/commonMain/kotlin/com/zup/nimbus/core/operations/logic.kt rename to src/commonMain/kotlin/com/zup/nimbus/core/ui/operations/logic.kt index d32be04..764fbc6 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/operations/logic.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ui/operations/logic.kt @@ -1,28 +1,27 @@ -package com.zup.nimbus.core.operations +package com.zup.nimbus.core.ui.operations -import com.zup.nimbus.core.OperationHandler +import com.zup.nimbus.core.ui.UILibrary import com.zup.nimbus.core.utils.then private fun toBooleanList(values: List): List { return values.filterIsInstance() } -internal fun getLogicOperations(): Map { - return mapOf( - "and" to { +internal fun registerLogicOperations(library: UILibrary) { + library + .addOperation("and") { !toBooleanList(it).contains(false) - }, - "or" to { + } + .addOperation("or") { toBooleanList(it).contains(true) - }, - "not" to { + } + .addOperation("not") { !(it[0] as Boolean) - }, - "condition" to { + } + .addOperation("condition") { val premise = it[0] as Boolean val trueValue = it[1] val falseValue = it[2] ((premise) then trueValue) ?: falseValue } - ) } diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/operations/number.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ui/operations/number.kt similarity index 70% rename from src/commonMain/kotlin/com/zup/nimbus/core/operations/number.kt rename to src/commonMain/kotlin/com/zup/nimbus/core/ui/operations/number.kt index 0ae0ce9..1799393 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/operations/number.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ui/operations/number.kt @@ -1,6 +1,6 @@ -package com.zup.nimbus.core.operations +package com.zup.nimbus.core.ui.operations -import com.zup.nimbus.core.OperationHandler +import com.zup.nimbus.core.ui.UILibrary import com.zup.nimbus.core.utils.div import com.zup.nimbus.core.utils.minus import com.zup.nimbus.core.utils.plus @@ -15,35 +15,34 @@ private fun toNumberList(values: List): List { return result } -internal fun getNumberOperations(): Map { - return mapOf( - "sum" to { +internal fun registerNumberOperations(library: UILibrary) { + library + .addOperation("sum"){ toNumberList(it).reduce { result, item -> result.plus(item) } - }, - "subtract" to { + } + .addOperation("subtract"){ toNumberList(it).reduce { result, item -> result.minus(item) } - }, - "multiply" to { + } + .addOperation("multiply"){ toNumberList(it).reduce { result, item -> result.times(item) } - }, - "divide" to { + } + .addOperation("divide"){ toNumberList(it).reduce { result, item -> result.div(item) } - }, - "gt" to { + } + .addOperation("gt"){ val (left, right) = toNumberList(it) left.toDouble() > right.toDouble() - }, - "gte" to { + } + .addOperation("gte"){ val (left, right) = toNumberList(it) left.toDouble() >= right.toDouble() - }, - "lt" to { + } + .addOperation("lt"){ val (left, right) = toNumberList(it) left.toDouble() < right.toDouble() - }, - "lte" to { + } + .addOperation("lte"){ val (left, right) = toNumberList(it) left.toDouble() <= right.toDouble() - }, - ) + } } diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/operations/other.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ui/operations/other.kt similarity index 78% rename from src/commonMain/kotlin/com/zup/nimbus/core/operations/other.kt rename to src/commonMain/kotlin/com/zup/nimbus/core/ui/operations/other.kt index 188fcfb..31a1da0 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/operations/other.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ui/operations/other.kt @@ -1,11 +1,11 @@ -package com.zup.nimbus.core.operations +package com.zup.nimbus.core.ui.operations -import com.zup.nimbus.core.OperationHandler +import com.zup.nimbus.core.ui.UILibrary @Suppress("ComplexMethod") -internal fun getOtherOperations(): Map { - return mapOf( - "contains" to { +internal fun registerOtherOperations(library: UILibrary) { + library + .addOperation("contains"){ val (collection, element) = it when (collection) { is List<*> -> collection.contains(element) @@ -13,8 +13,8 @@ internal fun getOtherOperations(): Map { is String -> collection.contains(element as String) else -> false } - }, - "concat" to { + } + .addOperation("concat"){ when (it[0]) { is List<*> -> { val result = ArrayList() @@ -32,24 +32,24 @@ internal fun getOtherOperations(): Map { } else -> it.reduce { result, item -> "${result}${item}" } } - }, - "length" to { + } + .addOperation("length"){ when (val collection = it[0]) { is List<*> -> collection.size is Map<*, *> -> collection.size is String -> collection.length else -> 0 } - }, - "eq" to { + } + .addOperation("eq"){ val (left, right) = it if (left is Number && right is Number) left.toDouble() == right.toDouble() else left == right - }, - "isNull" to { + } + .addOperation("isNull"){ it[0] == null - }, - "isEmpty" to { + } + .addOperation("isEmpty"){ when (val collection = it[0]) { is List<*> -> collection.isEmpty() is Map<*, *> -> collection.isEmpty() @@ -57,5 +57,4 @@ internal fun getOtherOperations(): Map { else -> collection == null } } - ) } diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/operations/string.kt b/src/commonMain/kotlin/com/zup/nimbus/core/ui/operations/string.kt similarity index 67% rename from src/commonMain/kotlin/com/zup/nimbus/core/operations/string.kt rename to src/commonMain/kotlin/com/zup/nimbus/core/ui/operations/string.kt index 046b70b..0fa5de0 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/operations/string.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/ui/operations/string.kt @@ -1,6 +1,6 @@ -package com.zup.nimbus.core.operations +package com.zup.nimbus.core.ui.operations -import com.zup.nimbus.core.OperationHandler +import com.zup.nimbus.core.ui.UILibrary import com.zup.nimbus.core.regex.replace import com.zup.nimbus.core.regex.matches import com.zup.nimbus.core.regex.toFastRegex @@ -9,30 +9,29 @@ private fun toStringList(values: List): List { return values.filterIsInstance() } -internal fun getStringOperations(): Map { - return mapOf( - "capitalize" to { +internal fun registerStringOperations(library: UILibrary) { + library + .addOperation("capitalize"){ (it[0] as String).replaceFirstChar { char -> char.uppercaseChar() } - }, - "lowercase" to { + } + .addOperation("lowercase"){ (it[0] as String).lowercase() - }, - "uppercase" to { + } + .addOperation("uppercase"){ (it[0] as String).uppercase() - }, - "match" to { + } + .addOperation("match"){ val (value, regex) = toStringList(it) value.matches(regex.toFastRegex()) - }, - "replace" to { + } + .addOperation("replace"){ val (value, regex, replace) = toStringList(it) value.replace(regex.toFastRegex(), replace) - }, - "substr" to { + } + .addOperation("substr"){ val value = it[0] as String val start = it[1] as Int val end = it.getOrNull(2) as Int? if (end == null) value.substring(start) else value.substring(start, end) } - ) } diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/utils/any.kt b/src/commonMain/kotlin/com/zup/nimbus/core/utils/any.kt index f5621b5..4ee1c6a 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/utils/any.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/utils/any.kt @@ -186,3 +186,18 @@ fun deepCopy(value: T): T { if (value is List<*>) return value.map { deepCopy(it) } as T return value } + +/** + * Recursively copies a value if it's a list or a map. Otherwise, it returns the received value. + * + * Attention: the copied lists and maps are mutable. + * + * @param value the value to copy. + * @return the copied value. + */ +@Suppress("UNCHECKED_CAST") +fun deepCopyMutable(value: T): T { + if (value is Map<*, *>) return mapValuesToMutableMap(value) { deepCopy(it.value) } as T + if (value is List<*>) return mapValuesToMutableList(value) { deepCopy(it) } as T + return value +} diff --git a/src/commonMain/kotlin/com/zup/nimbus/core/utils/json.kt b/src/commonMain/kotlin/com/zup/nimbus/core/utils/json.kt index 4cce2d2..18ade14 100644 --- a/src/commonMain/kotlin/com/zup/nimbus/core/utils/json.kt +++ b/src/commonMain/kotlin/com/zup/nimbus/core/utils/json.kt @@ -15,7 +15,7 @@ import kotlinx.serialization.json.longOrNull /* fixme: when we started this project we believed our maps and lists inside a node would need to be mutable, but we're currently very far into the implementation and we didn't need anything to be mutable. I already changed the types to immutable maps/lists in the RenderNode. But here, where we deserialize everything, we keep creating mutable data -structures, which is unnecessary. */ +structures, which is unnecessary.*/ /** * Transforms a JsonObject into a Kotlin Mutable Map recursively, i.e. this method will transform every JsonElement diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/EmptyNavigator.kt b/src/commonTest/kotlin/com.zup.nimbus.core/EmptyNavigator.kt deleted file mode 100644 index 0fab301..0000000 --- a/src/commonTest/kotlin/com.zup.nimbus.core/EmptyNavigator.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zup.nimbus.core - -import com.zup.nimbus.core.network.ViewRequest - -class EmptyNavigator: ServerDrivenNavigator { - override fun push(request: ViewRequest) {} - override fun pop() {} - override fun popTo(url: String) {} - override fun present(request: ViewRequest) {} - override fun dismiss() {} -} diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/NodeUtils.kt b/src/commonTest/kotlin/com.zup.nimbus.core/NodeUtils.kt index 8bb7606..36c4f40 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/NodeUtils.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/NodeUtils.kt @@ -1,40 +1,23 @@ package com.zup.nimbus.core -import com.zup.nimbus.core.tree.RenderNode +import com.zup.nimbus.core.tree.ServerDrivenEvent +import com.zup.nimbus.core.tree.dynamic.node.RootNode import com.zup.nimbus.core.tree.ServerDrivenNode +import com.zup.nimbus.core.tree.findNodeById object NodeUtils { - fun triggerEvent(node: ServerDrivenNode?, event: String, implicitStateValue: Any? = null) { - val action = node?.properties?.get(event) - if (action is Function<*>) (action as (implicitState: Any?) -> Unit)(implicitStateValue) + fun triggerEvent(node: ServerDrivenNode?, eventName: String, implicitStateValue: Any? = null) { + if (node == null) throw IllegalArgumentException("The node is null, can't trigger event") + val event = node.properties?.get(eventName) + if (event is ServerDrivenEvent) event.run(implicitStateValue) + else throw IllegalArgumentException("The event name \"$eventName\" does not correspond to an existing event.") } fun pressButton(screen: ServerDrivenNode?, buttonId: String) { if (screen == null) return - if (screen !is RenderNode) throw Error ("Expected a RenderNode") - val button = screen.findById(buttonId) ?: throw Error("Could not find button with id $buttonId") + val button = screen.findNodeById(buttonId) ?: throw Error("Could not find button with id $buttonId") triggerEvent(button, "onPress") } - // transforms a tree of nodes into a list of nodes (Depth First Search) - fun flatten(tree: ServerDrivenNode?): List { - val result = ArrayList() - if (tree != null) { - result.add(tree) - tree.children?.forEach { result.addAll(flatten(it)) } - } - return result - } - - /** - * runs findById on children, not on rawChildren - */ - fun findById(node: ServerDrivenNode, id: String): ServerDrivenNode? { - if (node.id == id) return node - node.children?.forEach { - val result = findById(it, id) - if (result != null) return result - } - return null - } + fun getContent(tree: RootNode): ServerDrivenNode = tree.children?.first()!! } diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/ObservableHttpClient.kt b/src/commonTest/kotlin/com.zup.nimbus.core/ObservableHttpClient.kt index d986310..da0dbe0 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/ObservableHttpClient.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/ObservableHttpClient.kt @@ -1,6 +1,5 @@ package com.zup.nimbus.core -import com.zup.nimbus.core.integration.navigation.BASE_URL import com.zup.nimbus.core.network.DefaultHttpClient import com.zup.nimbus.core.network.HttpClient import com.zup.nimbus.core.network.ServerDrivenRequest diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/ObservableNavigator.kt b/src/commonTest/kotlin/com.zup.nimbus.core/ObservableNavigator.kt index 6f44447..5591f89 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/ObservableNavigator.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/ObservableNavigator.kt @@ -2,19 +2,20 @@ package com.zup.nimbus.core import com.zup.nimbus.core.network.NetworkError import com.zup.nimbus.core.network.ViewRequest -import com.zup.nimbus.core.tree.MalformedComponentError +import com.zup.nimbus.core.tree.dynamic.builder.MalformedComponentError +import com.zup.nimbus.core.tree.dynamic.node.RootNode import kotlinx.coroutines.* import kotlinx.coroutines.test.TestScope @OptIn(ExperimentalCoroutinesApi::class) class ObservableNavigator( - private val scope: TestScope, + private val testScope: TestScope, private val nimbus: Nimbus, ): ServerDrivenNavigator { - var pages = ArrayList() - private var deferredPush: CompletableDeferred? = null + var pages = ArrayList() + private var deferredPush: CompletableDeferred? = null - suspend fun awaitPushCompletion(): Page { + suspend fun awaitPushCompletion(): RootNode { deferredPush = CompletableDeferred() return deferredPush!!.await() } @@ -24,12 +25,12 @@ class ObservableNavigator( } override fun push(request: ViewRequest) { - val view = nimbus.createView({ this }) - pages.add(Page(request.url, view)) - scope.launch { + val view = ServerDrivenView(nimbus, description = request.url) { this } + testScope.launch { try { val tree = nimbus.viewClient.fetch(request) - view.renderer.paint(tree) + tree.initialize(view) + pages.add(tree) deferredPush?.complete(pages.last()) } catch (e: NetworkError) { deferredPush?.cancel(e.message, e) @@ -55,3 +56,4 @@ class ObservableNavigator( // no need create an integration test for this (similar to pop) } } + diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/Page.kt b/src/commonTest/kotlin/com.zup.nimbus.core/Page.kt deleted file mode 100644 index dd99f7d..0000000 --- a/src/commonTest/kotlin/com.zup.nimbus.core/Page.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.zup.nimbus.core - -import com.zup.nimbus.core.render.ServerDrivenView -import com.zup.nimbus.core.tree.ServerDrivenNode - -class Page(val id: String, view: ServerDrivenView) { - var content: ServerDrivenNode? = null - - init { - view.onChange { content = it } - } -} diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/ViewObserver.kt b/src/commonTest/kotlin/com.zup.nimbus.core/ViewObserver.kt deleted file mode 100644 index f0a00ca..0000000 --- a/src/commonTest/kotlin/com.zup.nimbus.core/ViewObserver.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.zup.nimbus.core - -import com.zup.nimbus.core.render.ServerDrivenView -import com.zup.nimbus.core.tree.ServerDrivenNode -import kotlinx.coroutines.CompletableDeferred - -class ViewObserver(view: ServerDrivenView) { - var history = ArrayList() - private var changeCallback: (() -> Unit)? = null - - init { - view.onChange { - history.add(it) - changeCallback?.let { it() } - } - } - - suspend fun waitForChanges(totalNumberOfChanges: Int = 1): ArrayList { - if (history.size >= totalNumberOfChanges) return history - val deferred = CompletableDeferred>() - changeCallback = { - if (history.size >= totalNumberOfChanges) deferred.complete(history) - } - return deferred.await() - } - - fun clear() { - history = ArrayList() - changeCallback = null - } -} - -fun ServerDrivenView.observe(): ViewObserver { - return ViewObserver(this) -} diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/integration/ActionObserverTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/integration/ActionObserverTest.kt index b3a33af..5236a93 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/integration/ActionObserverTest.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/integration/ActionObserverTest.kt @@ -1,27 +1,30 @@ package com.zup.nimbus.core.integration -import com.zup.nimbus.core.* -import com.zup.nimbus.core.render.ActionEvent -import com.zup.nimbus.core.tree.RenderNode +import com.zup.nimbus.core.ActionTriggeredEvent +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.NodeUtils +import com.zup.nimbus.core.ObservableLogger +import com.zup.nimbus.core.ServerDrivenConfig import com.zup.nimbus.core.tree.ServerDrivenAction import com.zup.nimbus.core.tree.ServerDrivenNode +import com.zup.nimbus.core.tree.findNodeById +import com.zup.nimbus.core.ui.UILibrary import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertTrue class AnalyticsRecord( val platform: String, val action: ServerDrivenAction, val node: ServerDrivenNode, val event: String, - val screen: String, val timestamp: Long, ) /** * A simple analytics service that creates an analytics record if the action has `analytics = true` in its metadata. */ + class MyAnalyticsService { var entries = ArrayList() @@ -29,14 +32,13 @@ class MyAnalyticsService { entries = ArrayList() } - fun createRecord(event: ActionEvent) { + fun createRecord(event: ActionTriggeredEvent) { if (event.action.metadata?.get("analytics") != true) return entries.add(AnalyticsRecord( platform = "Test", action = event.action, - node = event.node, - event = event.name, - screen = event.view.description ?: "unknown", + node = event.scope.node, + event = event.scope.name, timestamp = 98844454548L, // in a real implementation, get the current unix time )) } @@ -47,6 +49,7 @@ private const val SCREEN = """{ "children": [ { "_:component": "layout:layoutHandler", + "id": "init", "properties": { "onInit": [{ "_:action": "log", @@ -62,6 +65,7 @@ private const val SCREEN = """{ }, { "_:component": "material:button", + "id": "btn-without-analytics", "properties": { "text": "Log without analytics", "onPress": [{ @@ -74,12 +78,13 @@ private const val SCREEN = """{ }, { "_:component": "material:button", + "id": "btn-with-analytics", "properties": { "text": "Push with analytics", "onPress": [{ - "_:action": "push", + "_:action": "log", "properties": { - "url": "/screen2" + "message": "Pressed" }, "metadata": { "analytics": true @@ -93,12 +98,14 @@ private const val SCREEN = """{ class ActionObserverTest { private val logger = ObservableLogger() private val analytics = MyAnalyticsService() - private val nimbus = Nimbus(ServerDrivenConfig( - baseUrl = "", - platform = "", - logger = logger, - actionObservers = listOf({ analytics.createRecord(it) }), - )) + private val nimbus = Nimbus( + ServerDrivenConfig( + baseUrl = "", + platform = "", + logger = logger, + ui = listOf(UILibrary().addActionObserver { analytics.createRecord(it) }) + ) + ) @BeforeTest fun clear() { @@ -108,62 +115,40 @@ class ActionObserverTest { @Test fun `should create an analytics record for log`() { - val view = nimbus.createView({ EmptyNavigator() }, "json") - val screen = RenderNode.fromJsonString(SCREEN, nimbus.idManager) - var hasRendered = false - view.renderer.paint(screen) - view.onChange { - val layoutHandler = it.children!![0] - NodeUtils.triggerEvent(layoutHandler, "onInit") - assertEquals(1, logger.entries.size) - assertEquals(1, analytics.entries.size) - val record = analytics.entries.first() - assertEquals("Test", record.platform) - assertEquals("log", record.action.action) - assertEquals(layoutHandler, record.node) - assertEquals("onInit", record.event) - assertEquals("json", record.screen) - assertEquals(98844454548L, record.timestamp) - hasRendered = true - } - assertTrue(hasRendered) + val screen = nimbus.nodeBuilder.buildFromJsonString(SCREEN) + screen.initialize(nimbus) + val layoutHandler = screen.findNodeById("init") + NodeUtils.triggerEvent(layoutHandler, "onInit") + assertEquals(1, logger.entries.size) + assertEquals(1, analytics.entries.size) + val record = analytics.entries.first() + assertEquals("Test", record.platform) + assertEquals("log", record.action.name) + assertEquals(layoutHandler, record.node) + assertEquals("onInit", record.event) + assertEquals(98844454548L, record.timestamp) } @Test fun `should not create an analytics record for log`() { - val view = nimbus.createView({ EmptyNavigator() }, "json") - val screen = RenderNode.fromJsonString(SCREEN, nimbus.idManager) - var hasRendered = false - view.renderer.paint(screen) - view.onChange { - val button = it.children!![1] - NodeUtils.triggerEvent(button, "onPress") - assertEquals(1, logger.entries.size) - assertEquals(0, analytics.entries.size) - hasRendered = true - } - assertTrue(hasRendered) + val screen = nimbus.nodeBuilder.buildFromJsonString(SCREEN) + screen.initialize(nimbus) + NodeUtils.pressButton(screen, "btn-without-analytics") + assertEquals(1, logger.entries.size) + assertEquals(0, analytics.entries.size) } @Test fun `should create an analytics record for push`() { - val view = nimbus.createView({ EmptyNavigator() }, "json") - val screen = RenderNode.fromJsonString(SCREEN, nimbus.idManager) - var hasRendered = false - view.renderer.paint(screen) - view.onChange { - val button = it.children!![2] - NodeUtils.triggerEvent(button, "onPress") - assertEquals(1, analytics.entries.size) - val record = analytics.entries.first() - assertEquals("Test", record.platform) - assertEquals("push", record.action.action) - assertEquals(button, record.node) - assertEquals("onPress", record.event) - assertEquals("json", record.screen) - assertEquals(98844454548L, record.timestamp) - hasRendered = true - } - assertTrue(hasRendered) + val screen = nimbus.nodeBuilder.buildFromJsonString(SCREEN) + screen.initialize(nimbus) + NodeUtils.pressButton(screen, "btn-with-analytics") + assertEquals(1, analytics.entries.size) + val record = analytics.entries.first() + assertEquals("Test", record.platform) + assertEquals("log", record.action.name) + assertEquals(screen.findNodeById("btn-with-analytics"), record.node) + assertEquals("onPress", record.event) + assertEquals(98844454548L, record.timestamp) } } diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/integration/StateResolutionTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/integration/StateResolutionTest.kt index 9c96d66..ae94134 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/integration/StateResolutionTest.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/integration/StateResolutionTest.kt @@ -1,11 +1,10 @@ package com.zup.nimbus.core.integration -import com.zup.nimbus.core.EmptyNavigator import com.zup.nimbus.core.Nimbus import com.zup.nimbus.core.ServerDrivenConfig +import com.zup.nimbus.core.tree.findNodeById import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertTrue private const val SCREEN = """{ "_:component": "layout:column", @@ -19,6 +18,7 @@ private const val SCREEN = """{ "children": [ { "_:component": "material:text", + "id": "formal", "properties": { "text": "@{greetings.formal}?" } @@ -31,6 +31,7 @@ private const val SCREEN = """{ }, "children": [{ "_:component": "material:text", + "id": "mundane", "properties": { "text": "@{greetings.mundane} @{firstName}?" } @@ -43,15 +44,10 @@ class StateResolutionTest { @Test fun shouldResolveStates() { val nimbus = Nimbus(ServerDrivenConfig("", "test")) - val node = nimbus.createNodeFromJson(SCREEN) - val page = nimbus.createView({ EmptyNavigator() }) - var hasRendered = false - page.renderer.paint(node) - page.onChange { - assertEquals("How are you?", it.children?.get(0)?.properties?.get("text")) - assertEquals("Whazap John?", it.children?.get(1)?.children?.get(0)?.properties?.get("text")) - hasRendered = true - } - assertTrue(hasRendered) + val tree = nimbus.nodeBuilder.buildFromJsonString(SCREEN) + tree.initialize(nimbus) + assertEquals("How are you?", tree.findNodeById("formal")?.properties?.get("text")) + assertEquals("Whazap John?", tree.findNodeById("mundane")?.properties?.get("text")) } } + diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/integration/forEach/ForEachTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/integration/forEach/ForEachTest.kt index 0220a24..44f3691 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/integration/forEach/ForEachTest.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/integration/forEach/ForEachTest.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.integration.forEach -import com.zup.nimbus.core.EmptyNavigator import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.NodeUtils import com.zup.nimbus.core.ServerDrivenConfig -import com.zup.nimbus.core.tree.RenderNode import com.zup.nimbus.core.tree.ServerDrivenNode +import com.zup.nimbus.core.tree.findNodeById import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -22,140 +22,140 @@ class ForEachTest { fun shouldCorrectlyProcessGeneralForEachScreen() { // WHEN the GENERAL_FOR_EACH screen is rendered val nimbus = Nimbus(ServerDrivenConfig("", "test")) - val node = nimbus.createNodeFromJson(GENERAL_FOR_EACH) - val page = nimbus.createView({ EmptyNavigator() }) - var hasRendered = false - page.renderer.paint(node) - page.onChange { - // THEN it should have replaced every forEach - assertFalse(getComponentsInTree(it).contains("forEach")) - // THEN it should have 6 children on the root: 3 original components + 1 column for the premium users + 1 - // component per basic user (2). The forEach with items = null should be just removed. - assertEquals(6, it.children?.size) - // THEN the column of premium users should have 3 components per user = 9 components - val premiumColumn = it.children?.get(1) - assertEquals(9, premiumColumn?.children?.size) - // THEN each component in the column of premium users should have the correct id - assertEquals("nimbus:5:0", premiumColumn?.children?.get(0)?.id) - assertEquals("nimbus:6:0", premiumColumn?.children?.get(1)?.id) - assertEquals("nimbus:7:0", premiumColumn?.children?.get(2)?.id) - assertEquals("nimbus:5:1", premiumColumn?.children?.get(3)?.id) - assertEquals("nimbus:6:1", premiumColumn?.children?.get(4)?.id) - assertEquals("nimbus:7:1", premiumColumn?.children?.get(5)?.id) - assertEquals("nimbus:5:2", premiumColumn?.children?.get(6)?.id) - assertEquals("nimbus:6:2", premiumColumn?.children?.get(7)?.id) - assertEquals("nimbus:7:2", premiumColumn?.children?.get(8)?.id) - // THEN each component in the column of premium users should have the correct text content - assertEquals(0, premiumColumn?.children?.get(0)?.properties?.get("text")) // 0: index - assertEquals("John", premiumColumn?.children?.get(1)?.properties?.get("text")) // 0: name - assertEquals(30, premiumColumn?.children?.get(2)?.properties?.get("text")) // 0: age - assertEquals(1, premiumColumn?.children?.get(3)?.properties?.get("text")) // 1: index - assertEquals("Mary", premiumColumn?.children?.get(4)?.properties?.get("text")) // 1: name - assertEquals(22, premiumColumn?.children?.get(5)?.properties?.get("text")) // 1: age - assertEquals(2, premiumColumn?.children?.get(6)?.properties?.get("text")) // 2: index - assertEquals("Anthony", premiumColumn?.children?.get(7)?.properties?.get("text")) // 2: name - assertEquals(5, premiumColumn?.children?.get(8)?.properties?.get("text")) // 2: age - // THEN the 2 components before the last (second forEach) should have the correct ids - assertEquals("nimbus:10:0", it.children?.get(3)?.id) - assertEquals("nimbus:10:1", it.children?.get(4)?.id) - // THEN the 2 components before the last (second forEach) should represent the two basic users - assertEquals("0. Rose 21", it.children?.get(3)?.properties?.get("text")) // 0: index. name age - assertEquals("1. Paul 54", it.children?.get(4)?.properties?.get("text")) // 1: index. name age - hasRendered = true - } - assertTrue(hasRendered) + val tree = nimbus.nodeBuilder.buildFromJsonString(GENERAL_FOR_EACH) + tree.initialize(nimbus) + assertFalse(getComponentsInTree(tree).contains("forEach")) + val column = NodeUtils.getContent(tree) + // THEN it should have 6 children on the root: 3 original components + 1 column for the premium users + 1 + // component per basic user (2). The forEach with items = null should be just removed. + assertEquals(6, column.children?.size) + // THEN the column of premium users should have 3 components per user = 9 components + val premiumColumn = column.children?.get(1) + assertEquals(9, premiumColumn?.children?.size) + // THEN each component in the column of premium users should have the correct id + assertEquals("nimbus:5:0", premiumColumn?.children?.get(0)?.id) + assertEquals("nimbus:6:0", premiumColumn?.children?.get(1)?.id) + assertEquals("nimbus:7:0", premiumColumn?.children?.get(2)?.id) + assertEquals("nimbus:5:1", premiumColumn?.children?.get(3)?.id) + assertEquals("nimbus:6:1", premiumColumn?.children?.get(4)?.id) + assertEquals("nimbus:7:1", premiumColumn?.children?.get(5)?.id) + assertEquals("nimbus:5:2", premiumColumn?.children?.get(6)?.id) + assertEquals("nimbus:6:2", premiumColumn?.children?.get(7)?.id) + assertEquals("nimbus:7:2", premiumColumn?.children?.get(8)?.id) + // THEN each component in the column of premium users should have the correct text content + assertEquals(0, premiumColumn?.children?.get(0)?.properties?.get("text")) // 0: index + assertEquals("John", premiumColumn?.children?.get(1)?.properties?.get("text")) // 0: name + assertEquals(30, premiumColumn?.children?.get(2)?.properties?.get("text")) // 0: age + assertEquals(1, premiumColumn?.children?.get(3)?.properties?.get("text")) // 1: index + assertEquals("Mary", premiumColumn?.children?.get(4)?.properties?.get("text")) // 1: name + assertEquals(22, premiumColumn?.children?.get(5)?.properties?.get("text")) // 1: age + assertEquals(2, premiumColumn?.children?.get(6)?.properties?.get("text")) // 2: index + assertEquals("Anthony", premiumColumn?.children?.get(7)?.properties?.get("text")) // 2: name + assertEquals(5, premiumColumn?.children?.get(8)?.properties?.get("text")) // 2: age + // THEN the 2 components before the last (second forEach) should have the correct ids + assertEquals("nimbus:10:0", column.children?.get(3)?.id) + assertEquals("nimbus:10:1", column.children?.get(4)?.id) + // THEN the 2 components before the last (second forEach) should represent the two basic users + assertEquals("0. Rose 21", column.children?.get(3)?.properties?.get("text")) // 0: index. name age + assertEquals("1. Paul 54", column.children?.get(4)?.properties?.get("text")) // 1: index. name age } @Test fun shouldCorrectlyProcessForEachWithStatesScreen() { // WHEN the FOR_EACH_WITH_STATES screen is rendered val nimbus = Nimbus(ServerDrivenConfig("", "test")) - val node = nimbus.createNodeFromJson(FOR_EACH_WITH_STATES) - val page = nimbus.createView({ EmptyNavigator() }) - var hasRendered = false - page.renderer.paint(node) - page.onChange { - // THEN it should have replaced the forEach - assertFalse(getComponentsInTree(it).contains("forEach")) - // THEN the column (root) should have three rows - val rows = it.children - assertEquals(3, rows?.size) - // THEN the first row should be correctly resolved - var rowContent = rows?.get(0)?.children - assertEquals("John", rowContent?.get(0)?.properties?.get("text")) - assertEquals("Increment listCounter: 0", rowContent?.get(1)?.properties?.get("text")) - assertEquals("Increment item counter: 0", rowContent?.get(2)?.properties?.get("text")) - // THEN the second row should be correctly resolved - rowContent = rows?.get(1)?.children - assertEquals("Mary", rowContent?.get(0)?.properties?.get("text")) - assertEquals("Increment listCounter: 0", rowContent?.get(1)?.properties?.get("text")) - assertEquals("Increment item counter: 0", rowContent?.get(2)?.properties?.get("text")) - // THEN the third row should be correctly resolved - rowContent = rows?.get(2)?.children - assertEquals("Anthony", rowContent?.get(0)?.properties?.get("text")) - assertEquals("Increment listCounter: 0", rowContent?.get(1)?.properties?.get("text")) - assertEquals("Increment item counter: 0", rowContent?.get(2)?.properties?.get("text")) - // todo: implement this as soon as we have both the setState and operations working - // WHEN the button to increment the item counter of the first row is pressed - // THEN this button should change its text to "Increment item counter: 1", but the other buttons should not change - // WHEN the button to increment the item counter of the second row is pressed - // THEN this button should change its text to "Increment item counter: 1", but the other buttons should not change - // WHEN the button to increment the item counter of the third row is pressed - // THEN this button should change its text to "Increment item counter: 1", but the other buttons should not change - // todo: implement this as soon as we have a solution for not replacing the nodes in the previous result of the - // forEach. If this test is implemented right now, it will reset the item counters to zero. - // WHEN the first button to increase the listCounter is pressed - // THEN every button to increase the listCounter should read "Increment listCounter: 1" - // THEN the item counter buttons should remain unchanged ("Increment item counter: 1") - hasRendered = true - } - assertTrue(hasRendered) + val tree = nimbus.nodeBuilder.buildFromJsonString(STATEFUL_FOR_EACH) + tree.initialize(nimbus) + // THEN it should have replaced the forEach + assertFalse(getComponentsInTree(tree).contains("forEach")) + // THEN the column (root) should have three rows + val rows = NodeUtils.getContent(tree).children + assertEquals(3, rows?.size) + // THEN the first row should be correctly resolved + var rowContent = rows?.get(0)?.children + assertEquals("John", rowContent?.get(0)?.properties?.get("text")) + assertEquals("Increment list counter: 0", rowContent?.get(1)?.properties?.get("text")) + assertEquals("Increment item counter: 0", rowContent?.get(2)?.properties?.get("text")) + // THEN the second row should be correctly resolved + rowContent = rows?.get(1)?.children + assertEquals("Mary", rowContent?.get(0)?.properties?.get("text")) + assertEquals("Increment list counter: 0", rowContent?.get(1)?.properties?.get("text")) + assertEquals("Increment item counter: 0", rowContent?.get(2)?.properties?.get("text")) + // THEN the third row should be correctly resolved + rowContent = rows?.get(2)?.children + assertEquals("Anthony", rowContent?.get(0)?.properties?.get("text")) + assertEquals("Increment list counter: 0", rowContent?.get(1)?.properties?.get("text")) + assertEquals("Increment item counter: 0", rowContent?.get(2)?.properties?.get("text")) + // WHEN the button to increment the item counter of the first row is pressed + val firstIncrementItemButton = tree.findNodeById("increment-item:0") + val secondIncrementItemButton = tree.findNodeById("increment-item:1") + val thirdIncrementItemButton = tree.findNodeById("increment-item:2") + NodeUtils.triggerEvent(firstIncrementItemButton, "onPress") + // THEN this button should change its text to "Increment item counter: 1", but the other buttons should not change + assertEquals("Increment item counter: 1", firstIncrementItemButton?.properties?.get("text")) + assertEquals("Increment item counter: 0", secondIncrementItemButton?.properties?.get("text")) + assertEquals("Increment item counter: 0", thirdIncrementItemButton?.properties?.get("text")) + // WHEN the button to increment the item counter of the second row is pressed + NodeUtils.triggerEvent(secondIncrementItemButton, "onPress") + // THEN this button should change its text to "Increment item counter: 1", but the other buttons should not change + assertEquals("Increment item counter: 1", firstIncrementItemButton?.properties?.get("text")) + assertEquals("Increment item counter: 1", secondIncrementItemButton?.properties?.get("text")) + assertEquals("Increment item counter: 0", thirdIncrementItemButton?.properties?.get("text")) + // WHEN the button to increment the item counter of the third row is pressed + NodeUtils.triggerEvent(thirdIncrementItemButton, "onPress") + // THEN this button should change its text to "Increment item counter: 1", but the other buttons should not change + assertEquals("Increment item counter: 1", firstIncrementItemButton?.properties?.get("text")) + assertEquals("Increment item counter: 1", secondIncrementItemButton?.properties?.get("text")) + assertEquals("Increment item counter: 1", thirdIncrementItemButton?.properties?.get("text")) + // WHEN the first button to increase the listCounter is pressed + val firstIncrementListButton = tree.findNodeById("increment-list:0") + val secondIncrementListButton = tree.findNodeById("increment-list:1") + val thirdIncrementListButton = tree.findNodeById("increment-list:2") + NodeUtils.triggerEvent(firstIncrementListButton, "onPress") + // THEN every button to increase the listCounter should read "Increment list counter: 1" + assertEquals("Increment list counter: 1", firstIncrementListButton?.properties?.get("text")) + assertEquals("Increment list counter: 1", secondIncrementListButton?.properties?.get("text")) + assertEquals("Increment list counter: 1", thirdIncrementListButton?.properties?.get("text")) + // AND the item counter buttons should remain unchanged ("Increment item counter: 1") + assertEquals("Increment item counter: 1", firstIncrementItemButton?.properties?.get("text")) + assertEquals("Increment item counter: 1", secondIncrementItemButton?.properties?.get("text")) + assertEquals("Increment item counter: 1", thirdIncrementItemButton?.properties?.get("text")) } @Test fun shouldCorrectlyProcessForEachWithKeyScreen() { // WHEN the FOR_EACH_WITH_KEY screen is rendered val nimbus = Nimbus(ServerDrivenConfig("", "test")) - val node = nimbus.createNodeFromJson(FOR_EACH_WITH_KEY) - val page = nimbus.createView({ EmptyNavigator() }) - var hasRendered = false - page.renderer.paint(node) - page.onChange { - // THEN it should have replaced the forEach - assertFalse(getComponentsInTree(it).contains("forEach")) - // THEN it should render three text components - assertEquals(3, it.children?.size) - // THEN the first text component should have the correct id - assertEquals("person:John", it.children?.get(0)?.id) - // THEN the second text component should have the correct id - assertEquals("person:Mary", it.children?.get(1)?.id) - // THEN the third text component should have the correct id - assertEquals("person:Anthony", it.children?.get(2)?.id) - // THEN the first text component should have the correct content - assertEquals("John: 30", it.children?.get(0)?.properties?.get("text")) - // THEN the second text component should have the correct content - assertEquals("Mary: 22", it.children?.get(1)?.properties?.get("text")) - // THEN the third text component should have the correct content - assertEquals("Anthony: 5", it.children?.get(2)?.properties?.get("text")) - hasRendered = true - } - assertTrue(hasRendered) + val tree = nimbus.nodeBuilder.buildFromJsonString(FOR_EACH_WITH_KEY) + tree.initialize(nimbus) + // THEN it should have replaced the forEach + assertFalse(getComponentsInTree(tree).contains("forEach")) + // THEN it should render three text components + val column = NodeUtils.getContent(tree) + assertEquals(3, column.children?.size) + // THEN the first text component should have the correct id + assertEquals("person:John", column.children?.get(0)?.id) + // THEN the second text component should have the correct id + assertEquals("person:Mary", column.children?.get(1)?.id) + // THEN the third text component should have the correct id + assertEquals("person:Anthony", column.children?.get(2)?.id) + // THEN the first text component should have the correct content + assertEquals("John: 30", column.children?.get(0)?.properties?.get("text")) + // THEN the second text component should have the correct content + assertEquals("Mary: 22", column.children?.get(1)?.properties?.get("text")) + // THEN the third text component should have the correct content + assertEquals("Anthony: 5", column.children?.get(2)?.properties?.get("text")) } @Test fun shouldCorrectlyProcessNestedEmptyForEachScreen() { // WHEN the NESTED_EMPTY_FOR_EACH screen is rendered val nimbus = Nimbus(ServerDrivenConfig("", "test")) - val node = nimbus.createNodeFromJson(NESTED_EMPTY_FOR_EACH) - val page = nimbus.createView({ EmptyNavigator() }) - var hasRendered = false - page.renderer.paint(node) - page.onChange { - // THEN the root should have no children - assertTrue(it.children?.isEmpty() != false) - hasRendered = true - } - assertTrue(hasRendered) + val tree = nimbus.nodeBuilder.buildFromJsonString(NESTED_EMPTY_FOR_EACH) + tree.initialize(nimbus) + val column = NodeUtils.getContent(tree) + // THEN the column should have no children + assertTrue(column.children?.isEmpty() != false) } private fun assertThatPlanColumnIsCorrect( @@ -186,49 +186,67 @@ class ForEachTest { fun shouldCorrectlyProcessNestedForEachScreen() { // WHEN the NESTED_FOR_EACH screen is rendered val nimbus = Nimbus(ServerDrivenConfig("", "test")) - val node = nimbus.createNodeFromJson(NESTED_FOR_EACH) - val page = nimbus.createView({ EmptyNavigator() }) - var hasRendered = false - page.renderer.paint(node) - page.onChange { - // THEN it should have replaced the forEach - assertFalse(getComponentsInTree(it).contains("forEach")) - // THEN it should have 3 column components as children of the root (one for each plan) - assertEquals(3, it.children?.size) + val tree = nimbus.nodeBuilder.buildFromJsonString(NESTED_FOR_EACH) + tree.initialize(nimbus) + // THEN it should have replaced the forEach + assertFalse(getComponentsInTree(tree).contains("forEach")) + val column = NodeUtils.getContent(tree) + // THEN it should have 3 column components as children of the root (one for each plan) + assertEquals(3, column.children?.size) - // THEN the column for the premium plan (first) should be correct - assertThatPlanColumnIsCorrect( - column = it.children?.get(0), - expectedHeaderId = "header:0", - expectedHeaderContent = "Documents of clients for the premium plan (59.9):", - expectedTextIds = listOf("document:0:0:0", "document:0:0:1", "document:0:1:0", "document:0:1:1", - "document:0:2:0", "document:0:2:1"), - expectedTextContent = listOf("045.445.875-96 (belonging to John)", "MG14785987 (belonging to John)", - "854.112.745-98 (belonging to Mary)", "SP51476321 (belonging to Mary)", - "856.334.857-85 (belonging to Anthony)", "PR14786320 (belonging to Anthony)"), - ) + // THEN the column for the premium plan (first) should be correct + assertThatPlanColumnIsCorrect( + column = column.children?.get(0), + expectedHeaderId = "header:0", + expectedHeaderContent = "Documents of clients for the premium plan (59.9):", + expectedTextIds = listOf("document:0:0:0", "document:0:0:1", "document:0:1:0", "document:0:1:1", + "document:0:2:0", "document:0:2:1"), + expectedTextContent = listOf("045.445.875-96 (belonging to John)", "MG14785987 (belonging to John)", + "854.112.745-98 (belonging to Mary)", "SP51476321 (belonging to Mary)", + "856.334.857-85 (belonging to Anthony)", "PR14786320 (belonging to Anthony)"), + ) - // THEN the column for the super plan (second) should be correct - assertThatPlanColumnIsCorrect( - column = it.children?.get(1), - expectedHeaderId = "header:1", - expectedHeaderContent = "Documents of clients for the super plan (39.9):", - expectedTextIds = listOf("document:1:0:0", "document:1:0:1"), - expectedTextContent = listOf("555.412.744-88 (belonging to Helen)", "MG45127889 (belonging to Helen)"), - ) + // THEN the column for the super plan (second) should be correct + assertThatPlanColumnIsCorrect( + column = column.children?.get(1), + expectedHeaderId = "header:1", + expectedHeaderContent = "Documents of clients for the super plan (39.9):", + expectedTextIds = listOf("document:1:0:0", "document:1:0:1"), + expectedTextContent = listOf("555.412.744-88 (belonging to Helen)", "MG45127889 (belonging to Helen)"), + ) - // THEN the column for the basic plan (third) should be correct - assertThatPlanColumnIsCorrect( - column = it.children?.get(2), - expectedHeaderId = "header:2", - expectedHeaderContent = "Documents of clients for the basic plan (19.9):", - expectedTextIds = listOf("document:2:0:0", "document:2:0:1", "document:2:1:0", "document:2:1:1"), - expectedTextContent = listOf("124.111.458-44 (belonging to Rose)", "RJ41775652 (belonging to Rose)", - "122.225.974-87 (belonging to Paul)", "SC41257896 (belonging to Paul)"), - ) + // THEN the column for the basic plan (third) should be correct + assertThatPlanColumnIsCorrect( + column = column.children?.get(2), + expectedHeaderId = "header:2", + expectedHeaderContent = "Documents of clients for the basic plan (19.9):", + expectedTextIds = listOf("document:2:0:0", "document:2:0:1", "document:2:1:0", "document:2:1:1"), + expectedTextContent = listOf("124.111.458-44 (belonging to Rose)", "RJ41775652 (belonging to Rose)", + "122.225.974-87 (belonging to Paul)", "SC41257896 (belonging to Paul)"), + ) + } - hasRendered = true - } - assertTrue(hasRendered) + @Test + fun `should add and remove elements in the dataset`() { + // WHEN the FOR_EACH_MUTABLE_DATASET screen is rendered + val nimbus = Nimbus(ServerDrivenConfig("", "test")) + val tree = nimbus.nodeBuilder.buildFromJsonString(FOR_EACH_MUTABLE_DATASET) + tree.initialize(nimbus) + val column = NodeUtils.getContent(tree).children?.first()!! + // THEN 3 texts + 2 buttons should be rendered + assertEquals(5, column.children?.size) + // WHEN the button to add one more item is pressed + NodeUtils.pressButton(column, "add") + // THEN 4 texts + 2 buttons should be rendered + assertEquals(6, column.children?.size) + // AND the last text must be "Paul" + assertEquals(column.children?.get(3)?.properties?.get("text"), "Paul") + // WHEN the button to remove the second item is pressed + NodeUtils.pressButton(column, "remove") + // THEN 3 texts + 2 buttons should be rendered + assertEquals(5, column.children?.size) + // AND the second text must be "Anthony" + assertEquals(column.children?.get(1)?.properties?.get("text"), "Anthony") } } + diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/integration/forEach/screens.kt b/src/commonTest/kotlin/com.zup.nimbus.core/integration/forEach/screens.kt index c0361fa..c81051d 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/integration/forEach/screens.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/integration/forEach/screens.kt @@ -97,7 +97,7 @@ const val GENERAL_FOR_EACH = """{ ] }""" -const val FOR_EACH_WITH_STATES = """{ +const val STATEFUL_FOR_EACH = """{ "_:component": "layout:column", "children": [ { @@ -125,23 +125,29 @@ const val FOR_EACH_WITH_STATES = """{ }, { "_:component": "material:button", + "id": "increment-list", "properties": { - "text": "Increment listCounter: @{listCounter}", + "text": "Increment list counter: @{listCounter}", "onPress": [{ "_:action": "setState", - "path": "listCounter", - "value": "@{sum(listCounter, 1)}" + "properties": { + "path": "listCounter", + "value": "@{sum(listCounter, 1)}" + } }] } }, { "_:component": "material:button", + "id": "increment-item", "properties": { "text": "Increment item counter: @{itemCounter}", "onPress": [{ "_:action": "setState", - "path": "itemCounter", - "value": "@{sum(itemCounter, 1)}" + "properties": { + "path": "itemCounter", + "value": "@{sum(itemCounter, 1)}" + } }] } } @@ -294,3 +300,85 @@ const val NESTED_FOR_EACH = """{ } ] }""" + +const val FOR_EACH_MUTABLE_DATASET = """{ + "_:component":"layout:column", + "state":{ + "id":"dataset", + "value":[ + { + "id":1, + "name":"John" + }, + { + "id":2, + "name":"Mary" + }, + { + "id":3, + "name":"Anthony" + } + ] + }, + "children":[ + { + "_:component":"layout:column", + "state":{ + "id":"newItem", + "value":{ + "id":4, + "name":"Paul" + } + }, + "children":[ + { + "_:component":"forEach", + "children":[ + { + "_:component":"layout:text", + "properties":{ + "text":"@{item.name}" + } + } + ], + "properties":{ + "key":"id", + "items":"@{dataset}" + } + }, + { + "_:component":"store:button", + "id":"add", + "properties":{ + "text":"Add one more", + "onPress":[ + { + "_:action":"setState", + "properties":{ + "path":"dataset", + "value":"@{insert(dataset, newItem)}" + } + } + ] + } + }, + { + "_:component":"store:button", + "id":"remove", + "properties":{ + "text":"Remove second", + "onPress":[ + { + "_:action":"setState", + "properties":{ + "path":"dataset", + "value":"@{removeIndex(dataset, 1)}" + } + } + ] + } + } + ] + } + ] +}""" diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/integration/ifThenElse/IfTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/integration/ifThenElse/IfTest.kt index e94a8f5..860cb94 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/integration/ifThenElse/IfTest.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/integration/ifThenElse/IfTest.kt @@ -1,128 +1,127 @@ package com.zup.nimbus.core.integration.ifThenElse -import com.zup.nimbus.core.EmptyNavigator import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.NodeUtils import com.zup.nimbus.core.ServerDrivenConfig -import com.zup.nimbus.core.component.MissingComponentError -import com.zup.nimbus.core.component.UnexpectedComponentError +import com.zup.nimbus.core.tree.ServerDrivenNode import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertTrue class IfTest { + private fun assertThenContent(content: List?, hasButton: Boolean = false) { + // THEN the if should be replaced by 2 (or 3 if it has a button) components + assertEquals(if (hasButton) 3 else 2, content?.size) + // AND the text of the first component should be "Good morning" + assertEquals("Good morning!", content?.get(0)?.properties?.get("text")) + // AND the image of the second component should be "sun" + assertEquals("sun", content?.get(1)?.properties?.get("id")) + } + + private fun assertElseContent(content: List?, hasButton: Boolean = false) { + // THEN the if should be replaced by 2 (or 3 if it has a button) components + assertEquals(if (hasButton) 3 else 2, content?.size) + // AND the text of the first component should be "Good evening" + assertEquals("Good evening!", content?.get(0)?.properties?.get("text")) + // AND the image of the second component should be "moon" + assertEquals("moon", content?.get(1)?.properties?.get("id")) + } + @Test fun `should render the content of Then when condition is true and no Else exists`() { // WHEN a screen with if (condition = true) and then is rendered val nimbus = Nimbus(ServerDrivenConfig("", "test")) - val node = nimbus.createNodeFromJson(createIfThenElseScreen(true)) - val page = nimbus.createView({ EmptyNavigator() }) - var hasRendered = false - page.renderer.paint(node) - page.onChange { - val ifResult = it.children - // THEN the if should be replaced by 2 components - assertEquals(2, ifResult?.size) - // AND the text of the first component should be "Good morning" - assertEquals("Good morning!", ifResult?.get(0)?.properties?.get("text")) - // AND the image of the second component should be "sun" - assertEquals("sun", ifResult?.get(1)?.properties?.get("id")) - hasRendered = true - } - assertTrue(hasRendered) + val content = nimbus.nodeBuilder.buildFromJsonString(createIfThenElseScreen(true)) + content.initialize(nimbus) + val ifResult = content.children?.first()?.children + assertThenContent(ifResult) } @Test fun `should render nothing when condition is false and no Else exists`() { // WHEN a screen with if (condition = false) and then is rendered val nimbus = Nimbus(ServerDrivenConfig("", "test")) - val node = nimbus.createNodeFromJson(createIfThenElseScreen(false)) - val page = nimbus.createView({ EmptyNavigator() }) - var hasRendered = false - page.renderer.paint(node) - page.onChange { - val ifResult = it.children - // THEN the if component should be removed - assertEquals(0, ifResult?.size) - hasRendered = true - } - assertTrue(hasRendered) + val content = nimbus.nodeBuilder.buildFromJsonString(createIfThenElseScreen(false)) + content.initialize(nimbus) + val ifResult = content.children?.first()?.children + // THEN the if component should be removed + assertEquals(0, ifResult?.size) } @Test fun `should render the content of Then when condition is true and Else exists`() { // WHEN a screen with if (condition = true), then and else is rendered val nimbus = Nimbus(ServerDrivenConfig("", "test")) - val node = nimbus.createNodeFromJson(createIfThenElseScreen(true, includeElse = true)) - val page = nimbus.createView({ EmptyNavigator() }) - var hasRendered = false - page.renderer.paint(node) - page.onChange { - val ifResult = it.children - // THEN the if should be replaced by 2 components - assertEquals(2, ifResult?.size) - // AND the text of the first component should be "Good morning" - assertEquals("Good morning!", ifResult?.get(0)?.properties?.get("text")) - // AND the image of the second component should be "sun" - assertEquals("sun", ifResult?.get(1)?.properties?.get("id")) - hasRendered = true - } - assertTrue(hasRendered) + val content = nimbus.nodeBuilder.buildFromJsonString(createIfThenElseScreen(true, includeElse = true)) + content.initialize(nimbus) + val ifResult = content.children?.first()?.children + assertThenContent(ifResult) } @Test fun `should render the content of Else when condition is false and Else exists`() { // WHEN a screen with if (condition = false), then and else is rendered val nimbus = Nimbus(ServerDrivenConfig("", "test")) - val node = nimbus.createNodeFromJson(createIfThenElseScreen(false, includeElse = true)) - val page = nimbus.createView({ EmptyNavigator() }) - var hasRendered = false - page.renderer.paint(node) - page.onChange { - val ifResult = it.children - // THEN the if should be replaced by 2 components - assertEquals(2, ifResult?.size) - // AND the text of the first component should be "Good evening" - assertEquals("Good evening!", ifResult?.get(0)?.properties?.get("text")) - // AND the image of the second component should be "moon" - assertEquals("moon", ifResult?.get(1)?.properties?.get("id")) - hasRendered = true - } - assertTrue(hasRendered) + val content = nimbus.nodeBuilder.buildFromJsonString(createIfThenElseScreen(false, includeElse = true)) + content.initialize(nimbus) + val ifResult = content.children?.first()?.children + assertElseContent(ifResult) } @Test - fun `should fail when a component different than Then or Else is passed to If`() { + fun `should render nothing when a component different than Then or Else is passed to If`() { // WHEN a screen with if and an invalid component is rendered val nimbus = Nimbus(ServerDrivenConfig("", "test")) - val node = nimbus.createNodeFromJson(createIfThenElseScreen(false, includeInvalid = true)) - val page = nimbus.createView({ EmptyNavigator() }) - var error: Throwable? = null - try { - page.renderer.paint(node) - } catch (e: Throwable) { - error = e - } - // Then it should fail - assertTrue(error is UnexpectedComponentError) + val content = nimbus.nodeBuilder.buildFromJsonString( + createIfThenElseScreen(false, includeInvalid = true) + ) + content.initialize(nimbus) + val ifResult = content.children?.first()?.children + // THEN the if component should be removed + assertEquals(0, ifResult?.size) } @Test - fun `should fail when If has no Then`() { + fun `should render the content of Else when If has no Then and condition is false`() { // WHEN a screen with if and else (but no then) is rendered val nimbus = Nimbus(ServerDrivenConfig("", "test")) - val node = nimbus.createNodeFromJson(createIfThenElseScreen( + val content = nimbus.nodeBuilder.buildFromJsonString(createIfThenElseScreen( false, includeThen = false, includeElse = true, )) - val page = nimbus.createView({ EmptyNavigator() }) - var error: Throwable? = null - try { - page.renderer.paint(node) - } catch (e: Throwable) { - error = e - } - // Then it should fail - assertTrue(error is MissingComponentError) + content.initialize(nimbus) + val ifResult = content.children?.first()?.children + assertElseContent(ifResult) + } + + @Test + fun `should toggle then-else content`() { + // WHEN a screen with if (condition = true) and then is rendered + val nimbus = Nimbus(ServerDrivenConfig("", "test")) + val content = nimbus.nodeBuilder.buildFromJsonString( + createIfThenElseScreen(true, includeElse = true, includeButton = true) + ) + content.initialize(nimbus) + val column = content.children?.first() + NodeUtils.pressButton(column, "toggle") + assertElseContent(column?.children, true) + NodeUtils.pressButton(column, "toggle") + assertThenContent(column?.children, true) + } + + @Test + fun `should create if-then-else behavior when if is the root node and declare its state`() { + // WHEN a screen with if as the root component is rendered + val nimbus = Nimbus(ServerDrivenConfig("", "test")) + val content = nimbus.nodeBuilder.buildFromJsonString(simpleRootIf) + content.initialize(nimbus) + // THEN the content of then should be rendered + assertEquals(1, content?.children?.size) + assertEquals("toggle-true", content?.children?.get(0)?.id) + // WHEN the button to toggle the state is pressed + NodeUtils.pressButton(content, "toggle-true") + // THEN the content of else should be rendered + assertEquals(1, content?.children?.size) + assertEquals("toggle-false", content?.children?.get(0)?.id) } } diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/integration/ifThenElse/screens.kt b/src/commonTest/kotlin/com.zup.nimbus.core/integration/ifThenElse/screens.kt index 8d92eaa..ff5c0df 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/integration/ifThenElse/screens.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/integration/ifThenElse/screens.kt @@ -5,6 +5,7 @@ fun createIfThenElseScreen( includeThen: Boolean = true, includeElse: Boolean = false, includeInvalid: Boolean = false, + includeButton: Boolean = false, ): String { val thenComponent = """{ "_:component": "then", @@ -49,6 +50,23 @@ fun createIfThenElseScreen( } }""" + val button = if (includeButton) """, { + "_:component": "material:button", + "id": "toggle", + "properties": { + "text": "toggle", + "onPress": [ + { + "_:action": "setState", + "properties": { + "path": "isMorning", + "value": "@{not(isMorning)}" + } + } + ] + } + }""" else "" + val components = ArrayList() if (includeThen) components.add(thenComponent) if (includeElse) components.add(elseComponent) @@ -69,7 +87,43 @@ fun createIfThenElseScreen( "children": [ ${components.joinToString(",")} ] - } + }$button ] }""" } + +private fun createRootIfButton(value: Boolean) = """{ + "_:component": "material:button", + "id": "toggle-$value", + "properties": { + "text": "value is $value", + "onPress": [{ + "_:action": "setState", + "properties": { + "path": "test", + "value": ${!value} + } + }] + } +}""" + +val simpleRootIf = """{ + "_:component": "if", + "state": { + "id": "test", + "value": true + }, + "properties": { + "condition": "@{test}" + }, + "children": [ + { + "_:component": "then", + "children": [${createRootIfButton(true)}] + }, + { + "_:component": "else", + "children": [${createRootIfButton(false)}] + } + ] +}""" diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/integration/navigation/NavigationTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/integration/navigation/NavigationTest.kt index 8493beb..9c005b7 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/integration/navigation/NavigationTest.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/integration/navigation/NavigationTest.kt @@ -4,6 +4,8 @@ import com.zup.nimbus.core.* import com.zup.nimbus.core.network.DefaultHttpClient import com.zup.nimbus.core.network.ResponseError import com.zup.nimbus.core.network.ViewRequest +import com.zup.nimbus.core.scope.closestScopeWithType +import com.zup.nimbus.core.tree.dynamic.node.RootNode import io.ktor.http.HttpStatusCode import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -21,12 +23,12 @@ class NavigationTest { )) private val navigator = ObservableNavigator(scope, nimbus) - private suspend fun pushViews(numberOfViews: Int): Page { + private suspend fun pushViews(numberOfViews: Int): RootNode { var current = 1 navigator.push(ViewRequest("/screen1")) var result = navigator.awaitPushCompletion() while (current < numberOfViews) { - NodeUtils.pressButton(result.content, "next") + NodeUtils.pressButton(result, "next") result = navigator.awaitPushCompletion() current++ } @@ -42,7 +44,7 @@ class NavigationTest { fun `should render the first view`() = scope.runTest { val page1 = pushViews(1) assertEquals(1, navigator.pages.size) - verifyScreen1(page1.content) + verifyScreen1(NodeUtils.getContent(page1)) } @Test @@ -64,21 +66,21 @@ class NavigationTest { fun `should push the second view`() = scope.runTest { val page2 = pushViews(2) assertEquals(2, navigator.pages.size) - verifyScreen2(page2.content) + verifyScreen2(NodeUtils.getContent(page2)) } @Test fun `should push the third view`() = scope.runTest { val page3 = pushViews(3) assertEquals(3, navigator.pages.size) - verifyScreen3(page3.content) + verifyScreen3(NodeUtils.getContent(page3)) } @Test fun `should show the fallback when pushing the fourth view`() = scope.runTest { val fallbackPage = pushViews(4) assertEquals(4, navigator.pages.size) - verifyFallbackScreen(fallbackPage.content) + NodeUtils.getContent(fallbackPage) } @Test @@ -86,7 +88,7 @@ class NavigationTest { val page3 = pushViews(3) var error: Throwable? = null try { - NodeUtils.pressButton(page3.content, "next-error") + NodeUtils.pressButton(page3, "next-error") navigator.awaitPushCompletion() } catch(e: CancellationException) { // this is super-weird, but on Android the error will be at e.cause.cause while on iOS it will be in e.cause @@ -99,25 +101,36 @@ class NavigationTest { @Test fun `should push the second view and pop`() = scope.runTest { val page2 = pushViews(2) - NodeUtils.pressButton(page2.content, "previous") + NodeUtils.pressButton(page2, "previous") assertEquals(1, navigator.pages.size) - assertEquals("/screen1", navigator.pages.last().id) + val view: ServerDrivenView? = navigator.pages.last().closestScopeWithType() + assertEquals("/screen1", view?.description) } @Test fun `should pop to root from fallback`() = scope.runTest { val fallbackPage = pushViews(4) + // fallback to /screen3 - NodeUtils.pressButton(fallbackPage.content, "previous") + NodeUtils.pressButton(fallbackPage, "previous") assertEquals(3, navigator.pages.size) - assertEquals("/screen3", navigator.pages.last().id) + var lastPage = navigator.pages.last() + var view: ServerDrivenView? = lastPage.closestScopeWithType() + assertEquals("/screen3", view?.description) + // /screen3 to /screen2 - NodeUtils.pressButton(navigator.pages.last().content, "previous") + NodeUtils.pressButton(lastPage, "previous") + lastPage = navigator.pages.last() + view = lastPage.closestScopeWithType() assertEquals(2, navigator.pages.size) - assertEquals("/screen2", navigator.pages.last().id) + assertEquals("/screen2", view?.description) + // /screen2 to /screen1 - NodeUtils.pressButton(navigator.pages.last().content, "previous") + NodeUtils.pressButton(lastPage, "previous") assertEquals(1, navigator.pages.size) - assertEquals("/screen1", navigator.pages.last().id) + lastPage = navigator.pages.last() + view = lastPage.closestScopeWithType() + assertEquals("/screen1", view?.description) } } + diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/integration/navigation/PreFetchTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/integration/navigation/PreFetchTest.kt index 477426d..d06d5ad 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/integration/navigation/PreFetchTest.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/integration/navigation/PreFetchTest.kt @@ -3,7 +3,7 @@ package com.zup.nimbus.core.integration.navigation import com.zup.nimbus.core.* import com.zup.nimbus.core.network.DefaultHttpClient import com.zup.nimbus.core.network.ViewRequest -import com.zup.nimbus.core.tree.ServerDrivenNode +import com.zup.nimbus.core.scope.closestState import kotlinx.coroutines.* import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest @@ -65,7 +65,7 @@ class PreFetchTest { // WHEN a request to /prefetch2 takes 1 second to complete httpClient.delayMsPerUrl["${BASE_URL}/prefetch2"] = 1000 // WHEN the user navigates to /prefetch2.json - NodeUtils.pressButton(prefetch1.content, "go-to-prefetch2") + NodeUtils.pressButton(prefetch1, "go-to-prefetch2") // THEN it should use the prefetched result to render /prefetch2. Since this test fails after 100ms and the request // will take 1s, it fails if the prefetched result is not used. navigator.awaitPushCompletion() @@ -89,7 +89,7 @@ class PreFetchTest { httpClient.awaitAllCurrentRequestsToFinish() httpClient.clear() // When the user navigates to /bad.json - NodeUtils.pressButton(prefetch1.content, "go-to-bad-url") + NodeUtils.pressButton(prefetch1, "go-to-bad-url") // THEN it should ignore the failed prefetched result and make a new network call AsyncUtils.flush() assertEquals(1, httpClient.entries.size) @@ -107,7 +107,7 @@ class PreFetchTest { AsyncUtils.flush() httpClient.clear() // When the user navigates to /prefetch2.json - NodeUtils.pressButton(prefetch1.content, "go-to-prefetch2") + NodeUtils.pressButton(prefetch1, "go-to-prefetch2") // WHEN we give enough time for every asynchronous pre-fetch to be triggered (less than 1 second) AsyncUtils.flush() // THEN it should await the existing request instead of making another one @@ -135,7 +135,7 @@ class PreFetchTest { httpClient.awaitAllCurrentRequestsToFinish() httpClient.clear() // WHEN the global state is updated and every node in the page is forced to refresh - nimbus.globalState.set("test") + nimbus.closestState("global")!!.set("test") // WHEN we give enough time for every asynchronous pre-fetch to be triggered AsyncUtils.flush() // THEN no prefetch should have been triggered again diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/integration/navigation/screenAssertions.kt b/src/commonTest/kotlin/com.zup.nimbus.core/integration/navigation/screenAssertions.kt index 94f4f1a..35acede 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/integration/navigation/screenAssertions.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/integration/navigation/screenAssertions.kt @@ -1,5 +1,6 @@ package com.zup.nimbus.core.integration.navigation +import com.zup.nimbus.core.tree.ServerDrivenEvent import com.zup.nimbus.core.tree.ServerDrivenNode import kotlin.test.assertEquals @@ -16,7 +17,7 @@ fun verifyScreen1(tree: ServerDrivenNode?) { assertEquals("material:button", button?.component) assertEquals(true, button?.id?.isNotBlank()) assertEquals("Next", button?.properties?.get("text")) - assertEquals(true, button?.properties?.get("onPress") is Function<*>) + assertEquals(true, button?.properties?.get("onPress") is ServerDrivenEvent) } fun verifyScreen2(tree: ServerDrivenNode?) { @@ -32,12 +33,12 @@ fun verifyScreen2(tree: ServerDrivenNode?) { assertEquals("material:button", nextButton?.component) assertEquals(true, nextButton?.id?.isNotBlank()) assertEquals("Next", nextButton?.properties?.get("text")) - assertEquals(true, nextButton?.properties?.get("onPress") is Function<*>) + assertEquals(true, nextButton?.properties?.get("onPress") is ServerDrivenEvent) val previousButton = tree?.children?.get(2) assertEquals("material:button", previousButton?.component) assertEquals(true, previousButton?.id?.isNotBlank()) assertEquals("Previous", previousButton?.properties?.get("text")) - assertEquals(true, previousButton?.properties?.get("onPress") is Function<*>) + assertEquals(true, previousButton?.properties?.get("onPress") is ServerDrivenEvent) } fun verifyScreen3(tree: ServerDrivenNode?) { @@ -53,17 +54,17 @@ fun verifyScreen3(tree: ServerDrivenNode?) { assertEquals("material:button", nextButtonFallback?.component) assertEquals(true, nextButtonFallback?.id?.isNotBlank()) assertEquals("Next (error with fallback)", nextButtonFallback?.properties?.get("text")) - assertEquals(true, nextButtonFallback?.properties?.get("onPress") is Function<*>) + assertEquals(true, nextButtonFallback?.properties?.get("onPress") is ServerDrivenEvent) val nextButtonException = tree?.children?.get(2) assertEquals("material:button", nextButtonException?.component) assertEquals(true, nextButtonException?.id?.isNotBlank()) assertEquals("Next (error without fallback)", nextButtonException?.properties?.get("text")) - assertEquals(true, nextButtonException?.properties?.get("onPress") is Function<*>) + assertEquals(true, nextButtonException?.properties?.get("onPress") is ServerDrivenEvent) val previousButton = tree?.children?.get(3) assertEquals("material:button", previousButton?.component) assertEquals(true, previousButton?.id?.isNotBlank()) assertEquals("Previous", previousButton?.properties?.get("text")) - assertEquals(true, previousButton?.properties?.get("onPress") is Function<*>) + assertEquals(true, previousButton?.properties?.get("onPress") is ServerDrivenEvent) } fun verifyFallbackScreen(tree: ServerDrivenNode?) { @@ -79,5 +80,5 @@ fun verifyFallbackScreen(tree: ServerDrivenNode?) { assertEquals("material:button", button?.component) assertEquals(true, button?.id?.isNotBlank()) assertEquals("Back to main flow", button?.properties?.get("text")) - assertEquals(true, button?.properties?.get("onPress") is Function<*>) + assertEquals(true, button?.properties?.get("onPress") is ServerDrivenEvent) } diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/integration/operations/OperationsTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/integration/operations/OperationsTest.kt index 363f5e2..811c987 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/integration/operations/OperationsTest.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/integration/operations/OperationsTest.kt @@ -1,6 +1,5 @@ package com.zup.nimbus.core.integration.operations -import com.zup.nimbus.core.EmptyNavigator import com.zup.nimbus.core.Nimbus import com.zup.nimbus.core.NodeUtils import com.zup.nimbus.core.ObservableLogger @@ -24,22 +23,20 @@ class OperationsTest { @Test fun `should add 1 to the count everytime the button is pressed`() { - val screen = nimbus.createNodeFromJson(FIRST_PAGE) - val view = nimbus.createView({ EmptyNavigator() }) - - view.renderer.paint(screen) - - var count = screen.state?.value as Number + val tree = nimbus.nodeBuilder.buildFromJsonString(FIRST_PAGE) + tree.initialize(nimbus) + val content = NodeUtils.getContent(tree) + var count = content.states?.first()?.value as Number assertEquals(1, count) - NodeUtils.pressButton(screen, "addToCount") + NodeUtils.pressButton(content, "addToCount") - count = screen.state?.value as Number + count = content.states?.first()?.value as Number assertEquals(2, count) - NodeUtils.pressButton(screen, "addToCount") + NodeUtils.pressButton(content, "addToCount") - count = screen.state?.value as Number + count = content.states?.first()?.value as Number assertEquals(3, count) } } diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/integration/sendRequest/SendRequestTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/integration/sendRequest/SendRequestTest.kt index 2ca191e..2e49691 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/integration/sendRequest/SendRequestTest.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/integration/sendRequest/SendRequestTest.kt @@ -1,10 +1,12 @@ package com.zup.nimbus.core.integration.sendRequest -import com.zup.nimbus.core.* +import com.zup.nimbus.core.AsyncUtils +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.NodeUtils +import com.zup.nimbus.core.ObservableLogger +import com.zup.nimbus.core.ServerDrivenConfig import com.zup.nimbus.core.log.LogLevel import com.zup.nimbus.core.network.DefaultHttpClient -import com.zup.nimbus.core.tree.ServerDrivenNode -import com.zup.nimbus.core.utils.valueOfKey import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest @@ -17,34 +19,24 @@ class SendRequestTest { private val scope = TestScope() private val logger = ObservableLogger() - private val nimbus = Nimbus(ServerDrivenConfig( - baseUrl = BASE_URL, - platform = "test", - httpClient = DefaultHttpClient(serverMock), - logger = logger, - )) - - private fun pressButtonToSendRequest(content: ServerDrivenNode) { - val button = content.children?.get(0)!! - val pressButton = valueOfKey<(value: Any?) -> Unit>(button.properties, "onPress") - pressButton(null) - } + private val nimbus = Nimbus( + ServerDrivenConfig( + baseUrl = BASE_URL, + platform = "test", + httpClient = DefaultHttpClient(serverMock), + logger = logger, + ) + ) private fun runSendRequestTest( json: String, numberOfLogsToWaitFor: Int = 2, onLogEvent: () -> Unit, ) = scope.runTest { - var changed = 0 - val screen = nimbus.createNodeFromJson(json) - val view = nimbus.createView({ EmptyNavigator() }) logger.clear() - view.onChange { - changed++ - pressButtonToSendRequest(it) - } - view.renderer.paint(screen) - assertEquals(1, changed) + val tree = nimbus.nodeBuilder.buildFromJsonString(json) + tree.initialize(nimbus) + NodeUtils.pressButton(tree, "send-request-btn") AsyncUtils.waitUntil { logger.entries.size >= numberOfLogsToWaitFor } onLogEvent() } diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/integration/sendRequest/serverMock.kt b/src/commonTest/kotlin/com.zup.nimbus.core/integration/sendRequest/serverMock.kt index f4b56a7..24419db 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/integration/sendRequest/serverMock.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/integration/sendRequest/serverMock.kt @@ -12,6 +12,7 @@ fun buildScreen(sendRequestUrl: String?, shouldHaveOnAndOnFinish: Boolean = true "children": [ { "_:component": "material:button", + "id": "send-request-btn", "properties": { "text": "Load", "onPress": [{ diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/integration/setContent/SetContentTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/integration/setContent/SetContentTest.kt deleted file mode 100644 index 1664e22..0000000 --- a/src/commonTest/kotlin/com.zup.nimbus.core/integration/setContent/SetContentTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.zup.nimbus.core.integration.setContent - -import com.zup.nimbus.core.EmptyNavigator -import com.zup.nimbus.core.Nimbus -import com.zup.nimbus.core.NodeUtils -import com.zup.nimbus.core.ObservableLogger -import com.zup.nimbus.core.ServerDrivenConfig -import com.zup.nimbus.core.log.LogLevel -import com.zup.nimbus.core.tree.ServerDrivenNode -import kotlin.test.Test -import kotlin.test.assertEquals - -class SetContentTest { - @Test - fun `should set the content`() { - // WHEN the SET_CONTENT_SCREEN is rendered - val logger = ObservableLogger() - val nimbus = Nimbus(ServerDrivenConfig("", "test", logger = logger)) - val node = nimbus.createNodeFromJson(SET_CONTENT_SCREEN) - val page = nimbus.createView({ EmptyNavigator() }) - var currentUI: ServerDrivenNode? = null - var renderCount = 0 - page.renderer.paint(node) - page.onChange { - currentUI = it - renderCount++ - } - // THEN there should be, in total, 8 nodes - assertEquals(8, NodeUtils.flatten(currentUI).size) - // AND the board should contain a single text: "Board" - var board = currentUI?.children?.get(0) - assertEquals(1, board?.children?.size) - assertEquals("Board", board?.children?.get(0)?.properties?.get("text")) - // WHEN the button to append a new component to the board is pressed - NodeUtils.pressButton(currentUI, "append") - // THEN there should be, in total, 9 nodes - assertEquals(2, renderCount) - assertEquals(9, NodeUtils.flatten(currentUI).size) - // AND the board should contain two texts in the order: "Board", "A new component" - board = currentUI?.children?.get(0) - assertEquals(2, board?.children?.size) - assertEquals("Board", board?.children?.get(0)?.properties?.get("text")) - assertEquals("A new component", board?.children?.get(1)?.properties?.get("text")) - // WHEN the button to prepend a new component to the board is pressed - NodeUtils.pressButton(currentUI, "prepend") - // THEN there should be, in total, 10 nodes - assertEquals(3, renderCount) - assertEquals(10, NodeUtils.flatten(currentUI).size) - // AND the board should contain three texts in the order: "A new component", "Board", "A new component" - board = currentUI?.children?.get(0) - assertEquals(3, board?.children?.size) - assertEquals("A new component", board?.children?.get(0)?.properties?.get("text")) - assertEquals("Board", board?.children?.get(1)?.properties?.get("text")) - assertEquals("A new component", board?.children?.get(2)?.properties?.get("text")) - // AND every new node generated until now should have been assigned a unique id - val ids = NodeUtils.flatten(currentUI).map { it.id } - val uniqueIds = ids.distinct() - assertEquals(ids.size, uniqueIds.size) - // WHEN the button to replace the board's content with a new component is pressed - NodeUtils.pressButton(currentUI, "replace") - // THEN there should be, in total, 8 nodes - assertEquals(4, renderCount) - assertEquals(8, NodeUtils.flatten(currentUI).size) - // AND the board should contain a single text: "A new component" - assertEquals(1, board?.children?.size) - assertEquals("A new component", board?.children?.get(0)?.properties?.get("text")) - // WHEN the button to replace the board's content with a new component is pressed - NodeUtils.pressButton(currentUI, "replaceItself") - // THEN there should be, in total, 7 nodes - assertEquals(5, renderCount) - assertEquals(7, NodeUtils.flatten(currentUI).size) - // THEN the board should not exist and a text written "A new component" should be in its place - assertEquals("A new component", currentUI?.children?.get(0)?.properties?.get("text")) - // AND no log should have been raised until now - assertEquals(0, logger.entries.size) - // WHEN the button to append a new component to the board is pressed again - NodeUtils.pressButton(currentUI, "append") - // THEN the UI should be left unchanged and an error should be logged since the board doesn't exist anymore - assertEquals(5, renderCount) - assertEquals(1, logger.entries.size) - assertEquals(LogLevel.Error, logger.entries[0].level) - } -} diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/integration/setContent/screens.kt b/src/commonTest/kotlin/com.zup.nimbus.core/integration/setContent/screens.kt deleted file mode 100644 index db40ecd..0000000 --- a/src/commonTest/kotlin/com.zup.nimbus.core/integration/setContent/screens.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.zup.nimbus.core.integration.setContent - -const val SET_CONTENT_SCREEN = """{ - "_:component": "layout:column", - "children": [ - { - "_:component": "layout:column", - "id": "board", - "children": [ - { - "_:component": "material:text", - "properties": { - "text": "Board" - } - } - ] - }, - { - "_:component": "layout:column", - "children": [ - { - "_:component": "material:button", - "id": "append", - "properties": { - "text": "Append component to Board", - "onPress": [{ - "_:action": "setContent", - "properties": { - "id": "board", - "value": { - "_:component": "material:text", - "properties": { - "text": "A new component" - } - } - } - }] - } - }, - { - "_:component": "material:button", - "id": "prepend", - "properties": { - "text": "Prepend component to Board", - "onPress": [{ - "_:action": "setContent", - "properties": { - "id": "board", - "mode": "Prepend", - "value": { - "_:component": "material:text", - "properties": { - "text": "A new component" - } - } - } - }] - } - }, - { - "_:component": "material:button", - "id": "replace", - "properties": { - "text": "Replace the Board's content with a new component", - "onPress": [{ - "_:action": "setContent", - "properties": { - "id": "board", - "mode": "Replace", - "value": { - "_:component": "material:text", - "properties": { - "text": "A new component" - } - } - } - }] - } - }, - { - "_:component": "material:button", - "id": "replaceItself", - "properties": { - "text": "Replace the Board itself with a new component", - "onPress": [{ - "_:action": "setContent", - "properties": { - "id": "board", - "mode": "ReplaceItself", - "value": { - "_:component": "material:text", - "properties": { - "text": "A new component" - } - } - } - }] - } - } - ] - } - ] -}""" diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/integration/setState/SetStateTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/integration/setState/SetStateTest.kt index 5a9df73..45dfd55 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/integration/setState/SetStateTest.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/integration/setState/SetStateTest.kt @@ -1,12 +1,10 @@ package com.zup.nimbus.core.integration.setState -import com.zup.nimbus.core.EmptyNavigator import com.zup.nimbus.core.Nimbus import com.zup.nimbus.core.NodeUtils import com.zup.nimbus.core.ObservableLogger import com.zup.nimbus.core.ServerDrivenConfig import com.zup.nimbus.core.log.LogLevel -import com.zup.nimbus.core.render.ServerDrivenView import com.zup.nimbus.core.tree.ServerDrivenNode import kotlin.test.BeforeTest import kotlin.test.Test @@ -16,20 +14,10 @@ import kotlin.test.assertTrue class SetStateTest { private val logger = ObservableLogger() private val nimbus = Nimbus(ServerDrivenConfig("", "test", logger = logger)) - private var currentUI: ServerDrivenNode? = null - private var numberOfRenders = 0 - private var view: ServerDrivenView? = null @BeforeTest fun setup() { logger.clear() - currentUI = null - numberOfRenders = 0 - view = nimbus.createView({ EmptyNavigator() }) - view?.onChange { - currentUI = it - numberOfRenders++ - } } private fun assertSetStateScreenIsCorrect( @@ -60,185 +48,172 @@ class SetStateTest { @Test fun `should set states`() { // WHEN the GENERAL_SET_STATE is rendered - val screen = nimbus.createNodeFromJson(GENERAL_SET_STATE) - view!!.renderer.paint(screen) + val tree = nimbus.nodeBuilder.buildFromJsonString(GENERAL_SET_STATE) + tree.initialize(nimbus) + val content = NodeUtils.getContent(tree) // THEN nothing should have been logged assertTrue(logger.entries.isEmpty()) - // AND the number of renders should be 1 - assertEquals(1, numberOfRenders) // AND the screen should have the correct content - assertSetStateScreenIsCorrect(currentUI) + assertSetStateScreenIsCorrect(content) // WHEN we press the button to set the name - NodeUtils.pressButton(currentUI, "setName") + NodeUtils.pressButton(content, "setName") // THEN nothing should have been logged assertTrue(logger.entries.isEmpty()) - // AND the number of renders should be 2 - assertEquals(2, numberOfRenders) // AND the screen should have the correct content - assertSetStateScreenIsCorrect(currentUI, "John") + assertSetStateScreenIsCorrect(content, "John") // WHEN we press the button to set the age - NodeUtils.pressButton(currentUI, "setAge") + NodeUtils.pressButton(content, "setAge") // THEN nothing should have been logged assertTrue(logger.entries.isEmpty()) - // AND the number of renders should be 3 - assertEquals(3, numberOfRenders) // AND the screen should have the correct content - assertSetStateScreenIsCorrect(currentUI, "John", 30) + assertSetStateScreenIsCorrect(content, "John", 30) // WHEN we press the button to set the button text - NodeUtils.pressButton(currentUI, "setButtonText") + NodeUtils.pressButton(content, "setButtonText") // THEN nothing should have been logged assertTrue(logger.entries.isEmpty()) - // AND the number of renders should be 4 - assertEquals(4, numberOfRenders) // AND the screen should have the correct content - assertSetStateScreenIsCorrect(currentUI, "John", 30, "bbb") + assertSetStateScreenIsCorrect(content, "John", 30, "bbb") } @Test fun `should not set state`() { // WHEN the UNREACHABLE_STATE screen is rendered - val screen = nimbus.createNodeFromJson(UNREACHABLE_STATE) - view!!.renderer.paint(screen) + val tree = nimbus.nodeBuilder.buildFromJsonString(UNREACHABLE_STATE) + tree.initialize(nimbus) + val content = NodeUtils.getContent(tree) // THEN nothing should've been logged assertTrue(logger.entries.isEmpty()) // WHEN the onInit lifecycle is run - NodeUtils.triggerEvent(currentUI, "onInit") + NodeUtils.triggerEvent(content, "onInit") // THEN an error should've been logged assertEquals(1, logger.entries.size) assertEquals(LogLevel.Error, logger.entries.first().level) - // AND the screen should not have been updated - assertEquals(1, numberOfRenders) // AND the text content should not have been changed - assertEquals("", currentUI?.children?.get(0)?.properties?.get("text")) + assertEquals("", content.children?.get(0)?.properties?.get("text")) } @Test fun `should change the state type`() { // WHEN the MANY_TYPES screen is rendered - val screen = nimbus.createNodeFromJson(MANY_TYPES) - view!!.renderer.paint(screen) + val tree = nimbus.nodeBuilder.buildFromJsonString(MANY_TYPES) + tree.initialize(nimbus) + val content = NodeUtils.getContent(tree) // THEN nothing should've been logged assertTrue(logger.entries.isEmpty()) // AND text should be the String "string" - assertEquals("string", currentUI?.children?.get(0)?.properties?.get("text")) + assertEquals("string", content.children?.get(0)?.properties?.get("text")) // WHEN we click the button to set the state value to Int - NodeUtils.pressButton(currentUI, "setInt") + NodeUtils.pressButton(content, "setInt") // THEN nothing should've been logged assertTrue(logger.entries.isEmpty()) // AND text should be the Int 10 - assertEquals(10, currentUI?.children?.get(0)?.properties?.get("text")) + assertEquals(10, content.children?.get(0)?.properties?.get("text")) // WHEN we click the button to set the state value to Double - NodeUtils.pressButton(currentUI, "setDouble") + NodeUtils.pressButton(content, "setDouble") // THEN nothing should've been logged assertTrue(logger.entries.isEmpty()) // AND text should be the Int 5.64 - assertEquals(5.64, currentUI?.children?.get(0)?.properties?.get("text")) + assertEquals(5.64, content.children?.get(0)?.properties?.get("text")) // WHEN we click the button to set the state value to Array - NodeUtils.pressButton(currentUI, "setArray") + NodeUtils.pressButton(content, "setArray") // THEN nothing should've been logged assertTrue(logger.entries.isEmpty()) // AND text should be the Array [0, 1, 2] - assertEquals(listOf(0, 1, 2), currentUI?.children?.get(0)?.properties?.get("text")) + assertEquals(listOf(0, 1, 2), content.children?.get(0)?.properties?.get("text")) // WHEN we click the button to set the state value to Map - NodeUtils.pressButton(currentUI, "setMap") + NodeUtils.pressButton(content, "setMap") // THEN nothing should've been logged assertTrue(logger.entries.isEmpty()) // AND text should be the Map { a: 0, b: 1 } - assertEquals(mapOf("a" to 0, "b" to 1), currentUI?.children?.get(0)?.properties?.get("text")) + assertEquals(mapOf("a" to 0, "b" to 1), content.children?.get(0)?.properties?.get("text")) // WHEN we click the button to set the state value to Boolean - NodeUtils.pressButton(currentUI, "setBoolean") + NodeUtils.pressButton(content, "setBoolean") // THEN nothing should've been logged assertTrue(logger.entries.isEmpty()) // AND text should be the Boolean true - assertEquals(true, currentUI?.children?.get(0)?.properties?.get("text")) + assertEquals(true, content.children?.get(0)?.properties?.get("text")) // WHEN we click the button to set the state value to null - NodeUtils.pressButton(currentUI, "setNull") + NodeUtils.pressButton(content, "setNull") // THEN nothing should've been logged assertTrue(logger.entries.isEmpty()) // AND text should be null - assertEquals(null, currentUI?.children?.get(0)?.properties?.get("text")) + assertEquals(null, content.children?.get(0)?.properties?.get("text")) // WHEN we click the button to set the state value to String - NodeUtils.pressButton(currentUI, "setString") + NodeUtils.pressButton(content, "setString") // THEN nothing should've been logged assertTrue(logger.entries.isEmpty()) // AND text should be the String "string" - assertEquals("string", currentUI?.children?.get(0)?.properties?.get("text")) - - // AND the screen should've been updated 7 times (8 renders) - assertEquals(8, numberOfRenders) + assertEquals("string", content.children?.get(0)?.properties?.get("text")) } @Test fun `should set deep state path`() { // WHEN the DEEP_STATE screen is rendered - val screen = nimbus.createNodeFromJson(DEEP_STATE) - view!!.renderer.paint(screen) + val tree = nimbus.nodeBuilder.buildFromJsonString(DEEP_STATE) + tree.initialize(nimbus) + val content = NodeUtils.getContent(tree) // THEN nothing should've been logged assertTrue(logger.entries.isEmpty()) // AND text should be { a: { b: { c: { d: { e: 0, f: 1 }, g: 2 } } } } assertEquals( mapOf("a" to mapOf("b" to mapOf("c" to mapOf("d" to mapOf("e" to 0, "f" to 1), "g" to 2)))), - currentUI?.children?.get(0)?.properties?.get("text"), + content.children?.get(0)?.properties?.get("text"), ) // WHEN we press the button to set set test.a.b.h.i to 3 - NodeUtils.pressButton(currentUI, "abhiTo3") + NodeUtils.pressButton(content, "abhiTo3") // THEN nothing should've been logged assertTrue(logger.entries.isEmpty()) // AND text should be { a: { b: { c: { d: { e: 0, f: 1 }, g: 2 }, h: { i: 3 } } } } assertEquals( mapOf("a" to mapOf("b" to mapOf("c" to mapOf("d" to mapOf("e" to 0, "f" to 1), "g" to 2), "h" to mapOf("i" to 3)))), - currentUI?.children?.get(0)?.properties?.get("text"), + content.children?.get(0)?.properties?.get("text"), ) // WHEN we press the button to set test.a.b.c.d.e to 4 - NodeUtils.pressButton(currentUI, "abcdeTo4") + NodeUtils.pressButton(content, "abcdeTo4") // THEN nothing should've been logged assertTrue(logger.entries.isEmpty()) // AND text should be { a: { b: { c: { d: { e: 4, f: 1 }, g: 2 }, h: { i: 3 } } } } assertEquals( mapOf("a" to mapOf("b" to mapOf("c" to mapOf("d" to mapOf("e" to 4, "f" to 1), "g" to 2), "h" to mapOf("i" to 3)))), - currentUI?.children?.get(0)?.properties?.get("text"), + content.children?.get(0)?.properties?.get("text"), ) // WHEN we press the button to set test.a.b to 5 - NodeUtils.pressButton(currentUI, "abTo5") + NodeUtils.pressButton(content, "abTo5") // THEN nothing should've been logged assertTrue(logger.entries.isEmpty()) // AND text should be { a: { b: { 5 } } - assertEquals(mapOf("a" to mapOf("b" to 5)), currentUI?.children?.get(0)?.properties?.get("text")) - - // AND the screen should've been updated 3 times (4 renders) - assertEquals(4, numberOfRenders) + assertEquals(mapOf("a" to mapOf("b" to 5)), content.children?.get(0)?.properties?.get("text")) } @Test fun `should set global state`() { // WHEN the GLOBAL_STATE screen is rendered - val screen = nimbus.createNodeFromJson(GLOBAL_STATE) - view!!.renderer.paint(screen) + val tree = nimbus.nodeBuilder.buildFromJsonString(GLOBAL_STATE) + tree.initialize(nimbus) + val content = NodeUtils.getContent(tree) // THEN nothing should've been logged assertTrue(logger.entries.isEmpty()) // AND text should be Hey ! - assertEquals("Hey !", currentUI?.children?.get(0)?.properties?.get("text")) + assertEquals("Hey !", content.children?.get(0)?.properties?.get("text")) // WHEN we press the button to set the username - NodeUtils.pressButton(currentUI, "setUserName") + NodeUtils.pressButton(content, "setUserName") // THEN nothing should've been logged assertTrue(logger.entries.isEmpty()) - // AND the screen should've been updated - assertEquals(2, numberOfRenders) // AND text should be Hey John! - assertEquals("Hey John!", currentUI?.children?.get(0)?.properties?.get("text")) + assertEquals("Hey John!", content.children?.get(0)?.properties?.get("text")) } } diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/performance/PerformanceTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/performance/PerformanceTest.kt index bb6cb5a..0f490a5 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/performance/PerformanceTest.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/performance/PerformanceTest.kt @@ -1,14 +1,12 @@ package com.zup.nimbus.core.performance -import com.zup.nimbus.core.EmptyNavigator import com.zup.nimbus.core.JsonLoader import com.zup.nimbus.core.Nimbus import com.zup.nimbus.core.NodeUtils -import com.zup.nimbus.core.OperationHandler import com.zup.nimbus.core.ServerDrivenConfig -import com.zup.nimbus.core.ViewObserver -import com.zup.nimbus.core.observe import com.zup.nimbus.core.tree.ServerDrivenNode +import com.zup.nimbus.core.tree.findNodeById +import com.zup.nimbus.core.ui.UILibrary import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest @@ -19,31 +17,27 @@ import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue -private const val MAX_AVERAGE_UPDATE_TIME_MS = 60 -private const val FOR_EACH_MAX_AVERAGE_UPDATE_TIME_MS = 80 +private const val MAX_AVERAGE_UPDATE_TIME_MS = 15 +private const val FOR_EACH_MAX_AVERAGE_UPDATE_TIME_MS = 15 private const val SHOULD_PRINT_TIMES = false @OptIn(ExperimentalCoroutinesApi::class) class PerformanceTest { private val scope = TestScope() - private val operations = mapOf( - "formatPrice" to { "US$ ${it[0]}" } - ) + private val uiLibrary = UILibrary() + .addOperation("formatPrice") { "US$ ${it[0]}" } - private suspend fun addToCart(observer: ViewObserver, productId: Int, totalRenders: Int): Long { - val content = observer.history.last() + private fun addToCart(content: ServerDrivenNode, productId: Int): Long { // do not use NodeUtils.pressButton here, we can't measure the time it takes to find the button. - val button = NodeUtils.findById(content, "add-to-cart:$productId") ?: throw Error("Could not find button") + val button = content.findNodeById("add-to-cart:$productId") ?: throw Error("Could not find button") val started = Clock.System.now().toEpochMilliseconds() NodeUtils.triggerEvent(button, "onPress") - observer.waitForChanges(totalRenders) val elapsed = Clock.System.now().toEpochMilliseconds() - started - val newButton = NodeUtils.findById(content, "add-to-cart:$productId") - val inCartText = NodeUtils.findById(content, "in-cart:$productId") + val newButton = content.findNodeById("add-to-cart:$productId") + val inCartText = content.findNodeById("in-cart:$productId") assertNull(newButton) assertEquals("In cart ✓", inCartText?.properties?.get("text")) - clean(content) return elapsed } @@ -54,24 +48,15 @@ class PerformanceTest { return "${intValue}.$decimalValue" } - private fun clean(node: ServerDrivenNode) { - node.dirty = false - node.children?.forEach { clean(it) } - } - - private suspend fun runPerformanceTest(jsonFileName: String, maxTimeMs: Int) { + private fun runPerformanceTest(jsonFileName: String, maxTimeMs: Int) { val json = JsonLoader.loadJson(jsonFileName) - val nimbus = Nimbus(ServerDrivenConfig("", "test", operations = operations)) - val node = nimbus.createNodeFromJson(json) - val page = nimbus.createView({ EmptyNavigator() }) - val observer = page.observe() + val nimbus = Nimbus(ServerDrivenConfig("", "test", ui = listOf(uiLibrary))) val started = Clock.System.now().toEpochMilliseconds() - page.renderer.paint(node) - observer.waitForChanges() + val content = nimbus.nodeBuilder.buildFromJsonString(json) + content.initialize(nimbus) val times = mutableListOf(Clock.System.now().toEpochMilliseconds() - started) - clean(observer.history.last()) for (i in 1..20) { - times.add(addToCart(observer, i, i + 1)) + times.add(addToCart(content, i)) } val updates = times.drop(1) @@ -98,3 +83,4 @@ class PerformanceTest { runPerformanceTest("products-forEach", FOR_EACH_MAX_AVERAGE_UPDATE_TIME_MS) } } + diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/FastRegexTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/FastRegexTest.kt index c4bb0f2..40375a8 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/FastRegexTest.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/FastRegexTest.kt @@ -149,4 +149,29 @@ class FastRegexTest { val regex = FastRegex("""(^\w+)|(\.\w+)|(\[\d+\])""") assertFalse(regex.containsMatchIn("...")) } + + @Test + fun `should transform String into something else according to a pattern`() { + class IntOrString( + val str: String? = null, + val int: Int? = null, + ) { + override fun toString(): String { + return if (str == null) "$int" else "\"$str\"" + } + + override fun equals(other: Any?): Boolean { + return other is IntOrString && other.int == int && other.str == str + } + } + val pattern = FastRegex("""\d+""") + val result = pattern.transform("Hello 123 4 W0rld!", { IntOrString(str = it) }) { + IntOrString(int = it.values.first().toInt()) + } + assertEquals( + listOf(IntOrString(str = "Hello "), IntOrString(int = 123), IntOrString(str = " "), IntOrString(int = 4), + IntOrString(str = " W"), IntOrString(int = 0), IntOrString(str = "rld!")), + result, + ) + } } diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/tree/ServerDrivenStateTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/ServerDrivenState.kt similarity index 71% rename from src/commonTest/kotlin/com.zup.nimbus.core/unity/tree/ServerDrivenStateTest.kt rename to src/commonTest/kotlin/com.zup.nimbus.core/unity/ServerDrivenState.kt index 3b86709..88591ce 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/tree/ServerDrivenStateTest.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/ServerDrivenState.kt @@ -1,33 +1,19 @@ -package com.zup.nimbus.core.unity.tree +package com.zup.nimbus.core.unity -import com.zup.nimbus.core.tree.RenderNode -import com.zup.nimbus.core.tree.ServerDrivenState +import com.zup.nimbus.core.ServerDrivenState import kotlin.test.Test import kotlin.test.assertEquals -class ServerDrivenStateTest { - private val parentNode = RenderNode( - "nodeId", - "column", - null, - null, - null, - null, - null, - null, - null, - null - ) +class ServerDrivenStateTest { @Test fun `should create a primitive typed state with a determined id`() { val stateId = "testState" val stateValue = "Test state value" - val state = ServerDrivenState(stateId, stateValue, parentNode) + val state = ServerDrivenState(stateId, stateValue) assertEquals(stateId, state.id) assertEquals(stateValue, state.value) - assertEquals(parentNode, state.parent) } @Test @@ -35,7 +21,7 @@ class ServerDrivenStateTest { val stateId = "testState" val stateValue = "Test state value" val stateUpdatedValue = "This is the updated test state value" - val state = ServerDrivenState(stateId, stateValue, parentNode) + val state = ServerDrivenState(stateId, stateValue) assertEquals(stateValue, state.value) state.set(stateUpdatedValue, "") @@ -49,11 +35,10 @@ class ServerDrivenStateTest { "a" to "foo", "b" to "bar" ) - val state = ServerDrivenState(stateId, stateValue, parentNode) + val state = ServerDrivenState(stateId, stateValue) assertEquals(stateId, state.id) assertEquals(stateValue, state.value) - assertEquals(parentNode, state.parent) } @Test @@ -63,7 +48,7 @@ class ServerDrivenStateTest { "a" to "foo", "b" to "bar" ) - val state = ServerDrivenState(stateId, stateValue, parentNode) + val state = ServerDrivenState(stateId, stateValue) assertEquals(stateValue, state.value) assertEquals("bar", (state.value as Map<*, *>)["b"]) @@ -75,11 +60,10 @@ class ServerDrivenStateTest { fun `should create an list state with a determined id`() { val stateId = "testState" val stateValue = listOf ("a", "b", "c") - val state = ServerDrivenState(stateId, stateValue, parentNode) + val state = ServerDrivenState(stateId, stateValue) assertEquals(stateId, state.id) assertEquals(stateValue, state.value) - assertEquals(parentNode, state.parent) } @Test @@ -87,7 +71,7 @@ class ServerDrivenStateTest { val stateId = "testState" val stateValue = listOf ("a", "b", "c") val stateUpdatedValue = listOf ("a", "foo bar", "c") - val state = ServerDrivenState(stateId, stateValue, parentNode) + val state = ServerDrivenState(stateId, stateValue) assertEquals(stateValue, state.value) assertEquals("b", (state.value as List<*>)[1]) @@ -97,3 +81,4 @@ class ServerDrivenStateTest { assertEquals("c", (state.value as List<*>)[2]) } } + diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/action/ConditionTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/action/ConditionTest.kt index 74dedbe..af6c90e 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/action/ConditionTest.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/action/ConditionTest.kt @@ -1,88 +1,69 @@ package com.zup.nimbus.core.unity.action -import com.zup.nimbus.core.EmptyNavigator +import com.zup.nimbus.core.ActionTriggeredEvent import com.zup.nimbus.core.Nimbus import com.zup.nimbus.core.ObservableLogger import com.zup.nimbus.core.ServerDrivenConfig -import com.zup.nimbus.core.action.condition import com.zup.nimbus.core.log.LogLevel -import com.zup.nimbus.core.render.ActionEvent -import com.zup.nimbus.core.render.ServerDrivenView -import com.zup.nimbus.core.tree.RenderAction -import com.zup.nimbus.core.tree.RenderNode -import kotlin.test.BeforeTest +import com.zup.nimbus.core.scope.Scope +import com.zup.nimbus.core.tree.ServerDrivenEvent +import com.zup.nimbus.core.ui.action.condition import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertTrue class ConditionTest { - private val logger = ObservableLogger() - private val nimbus = Nimbus(ServerDrivenConfig("", "", logger = logger)) - - private fun createEvent( + private fun createActionTriggeredEvent( conditionValue: Boolean? = null, - onTrue: ((_: Any?) -> Unit)? = null, - onFalse: ((_: Any?) -> Unit)? = null, - ): ActionEvent { - return ActionEvent( - action = RenderAction( - action = "condition", - properties = mapOf("condition" to conditionValue, "onTrue" to onTrue, "onFalse" to onFalse), - metadata = null, - rawProperties = null, - rawMetadata = null, - ), - view = ServerDrivenView(nimbus, { EmptyNavigator() }), - name = "event", - node = RenderNode.empty(), - ) - } - - @BeforeTest - fun clear() { - logger.clear() + onTrue: ServerDrivenEvent? = null, + onFalse: ServerDrivenEvent? = null, + parent: Scope? = null + ): ActionTriggeredEvent { + val action = SimpleAction("condition", { condition(it) }, mapOf( + "condition" to conditionValue, + "onTrue" to onTrue, + "onFalse" to onFalse, + )) + return ActionTriggeredEvent(action = action, scope = SimpleEvent(parent = parent), dependencies = mutableSetOf()) } @Test fun `should run onTrue if condition is true`() { - var called = false - val event = createEvent(conditionValue = true, onTrue = { called = true }) + val onTrue = SimpleEvent() + val event = createActionTriggeredEvent(conditionValue = true, onTrue = onTrue) condition(event) - assertTrue(called) - assertTrue(logger.entries.isEmpty()) + assertEquals(1, onTrue.calls.size) } @Test fun `should run onFalse if condition is false`() { - var called = false - val event = createEvent(conditionValue = false, onFalse = { called = true }) + val onFalse = SimpleEvent() + val event = createActionTriggeredEvent(conditionValue = false, onFalse = onFalse) condition(event) - assertTrue(called) - assertTrue(logger.entries.isEmpty()) + assertEquals(1, onFalse.calls.size) } @Test fun `should do nothing if condition is true and onTrue is not provided`() { - var called = false - val event = createEvent(conditionValue = true, onFalse = { called = true }) + val onFalse = SimpleEvent() + val event = createActionTriggeredEvent(conditionValue = true, onFalse = onFalse) condition(event) - assertFalse(called) - assertTrue(logger.entries.isEmpty()) + assertTrue(onFalse.calls.isEmpty()) } @Test fun `should do nothing if condition is false and onFalse is not provided`() { - var called = false - val event = createEvent(conditionValue = false, onTrue = { called = true }) + val onTrue = SimpleEvent() + val event = createActionTriggeredEvent(conditionValue = false, onTrue = onTrue) condition(event) - assertFalse(called) - assertTrue(logger.entries.isEmpty()) + assertTrue(onTrue.calls.isEmpty()) } @Test fun `should fail if condition is not provided`() { - val event = createEvent() + val logger = ObservableLogger() + val nimbus = Nimbus(ServerDrivenConfig(baseUrl = "", platform = "test", logger = logger)) + val event = createActionTriggeredEvent(parent = nimbus) condition(event) assertEquals(1, logger.entries.size) assertEquals(LogLevel.Error, logger.entries[0].level) diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/action/SimpleAction.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/action/SimpleAction.kt new file mode 100644 index 0000000..dfe80e5 --- /dev/null +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/action/SimpleAction.kt @@ -0,0 +1,14 @@ +package com.zup.nimbus.core.unity.action + +import com.zup.nimbus.core.ActionHandler +import com.zup.nimbus.core.scope.Scope +import com.zup.nimbus.core.tree.ServerDrivenAction + +class SimpleAction( + override val name: String, + override val handler: ActionHandler, + override val properties: Map? = null, + override val metadata: Map? = null, +) : ServerDrivenAction { + override fun update() = throw NotImplementedError() +} diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/action/SimpleEvent.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/action/SimpleEvent.kt new file mode 100644 index 0000000..c063f39 --- /dev/null +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/action/SimpleEvent.kt @@ -0,0 +1,39 @@ +package com.zup.nimbus.core.unity.action + +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.ServerDrivenState +import com.zup.nimbus.core.ServerDrivenView +import com.zup.nimbus.core.dependency.CommonDependency +import com.zup.nimbus.core.scope.CommonScope +import com.zup.nimbus.core.scope.Scope +import com.zup.nimbus.core.scope.closestScopeWithType +import com.zup.nimbus.core.tree.ServerDrivenAction +import com.zup.nimbus.core.tree.ServerDrivenEvent +import com.zup.nimbus.core.tree.ServerDrivenNode + +class SimpleEvent( + override val name: String = "mock", + parent: Scope? = null, + states: List? = null, +): ServerDrivenEvent, CommonDependency(), Scope by CommonScope(states, parent) { + var calls: MutableList = mutableListOf() + override val actions: List = emptyList() + override val node: ServerDrivenNode + get() = throw NotImplementedError() + override val view: ServerDrivenView + get() = throw NotImplementedError() + override val nimbus: Nimbus + get() = closestScopeWithType() ?: throw IllegalStateException("Nimbus is not available") + + override fun run() { + calls.add(null) + } + + override fun run(implicitStateValue: Any?) { + calls.add(implicitStateValue) + } + + fun clear() { + calls = mutableListOf() + } +} diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/expression/expression.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/expression/expression.kt new file mode 100644 index 0000000..fd37d25 --- /dev/null +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/expression/expression.kt @@ -0,0 +1,402 @@ +package com.zup.nimbus.core.unity.expression + +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.ServerDrivenConfig +import com.zup.nimbus.core.ServerDrivenState +import com.zup.nimbus.core.expression.Operation +import com.zup.nimbus.core.expression.StateReference +import com.zup.nimbus.core.expression.StringTemplate +import com.zup.nimbus.core.expression.parser.ExpressionParser +import com.zup.nimbus.core.scope.StateOnlyScope +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ExpressionTest { + private val nimbus = Nimbus(ServerDrivenConfig(baseUrl = "", platform = "test")) + private val parser = ExpressionParser(nimbus) + + // #Tests for function "containsExpression" + @Test + fun `should find expression inside a text`() { + val contains = parser.containsExpression("This is a text with an @{expression} inside of the text!") + assertEquals(true, contains) + } + + @Test + fun `should find expression when expression is whole text`() { + val contains = parser.containsExpression("@{expression}") + assertEquals(true, contains) + } + + @Test + fun `should not find expression when there is no expression`() { + val contains = parser.containsExpression("This is a text with no expression inside of the text!") + assertEquals(false, contains) + } + + // ##State Bindings + @Test + fun `should replace by state string`() { + val expectedResult = "Hello World!" + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", expectedResult)), + ) + val expression = parser.parseString("@{sds}") as StateReference + expression.initialize(scope) + assertEquals(expectedResult, expression.getValue()) + } + + @Test + fun `should replace by state number`() { + val expectedResult = 584 + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", expectedResult)), + ) + val expression = parser.parseString("@{sds}") as StateReference + expression.initialize(scope) + assertEquals(expectedResult, expression.getValue()) + } + + @Test + fun `should replace by state float number`() { + val expectedResult = 584.73 + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", expectedResult)), + ) + val expression = parser.parseString("@{sds}") as StateReference + expression.initialize(scope) + assertEquals(expectedResult, expression.getValue()) + } + + @Test + fun `should replace by state boolean`() { + val expectedResult = true + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", expectedResult)), + ) + val expression = parser.parseString("@{sds}") as StateReference + expression.initialize(scope) + assertEquals(expectedResult, expression.getValue()) + } + + @Test + fun `should replace by state array`() { + val expectedResult = listOf(1, 2, 3, 4) + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", expectedResult)), + ) + val expression = parser.parseString("@{sds}") as StateReference + expression.initialize(scope) + assertEquals(expectedResult, expression.getValue()) + } + + @Test + fun `should replace by state object`() { + val expectedResult = mapOf( + "firstName" to "Test", + "lastName" to "de Oliveira", + "email" to "testdeoliveira@kotlintest.com" + ) + + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", expectedResult)), + ) + val expression = parser.parseString("@{sds}") as StateReference + expression.initialize(scope) + assertEquals(expectedResult, expression.getValue()) + } + + @Test + fun `should replace binding in the middle of a text string`() { + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", "Hello World")), + ) + val expression = parser.parseString("Mid text expression: @{sds}.") as StringTemplate + expression.initialize(scope) + assertEquals("Mid text expression: Hello World.", expression.getValue()) + } + + @Test + fun `should replace binding in the middle of a text number`() { + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", 584)), + ) + val expression = parser.parseString("Mid text expression: @{sds}.") as StringTemplate + expression.initialize(scope) + assertEquals("Mid text expression: 584.", expression.getValue()) + } + + @Test + fun `should replace binding in the middle of a text boolean`() { + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", true)), + ) + val expression = parser.parseString("Mid text expression: @{sds}.") as StringTemplate + expression.initialize(scope) + assertEquals("Mid text expression: true.", expression.getValue()) + } + + @Test + fun `should replace binding in the middle of a text array as string`() { + val stateValue = listOf(1, 2, 3, 4) + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", stateValue)), + ) + val expression = parser.parseString("Mid text expression: @{sds}.") as StringTemplate + expression.initialize(scope) + assertEquals("Mid text expression: ${stateValue}.", expression.getValue()) + } + + @Test + fun `should replace binding in the middle of a text object as string`() { + val person = mapOf( + "firstName" to "Test", + "lastName" to "de Oliveira", + "email" to "testdeoliveira@kotlintest.com" + ) + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", person)), + ) + val expression = parser.parseString("Mid text expression: @{sds}.") as StringTemplate + expression.initialize(scope) + assertEquals("Mid text expression: $person.", expression.getValue()) + } + + @Test + fun `should replace binding in the middle of a text object key`() { + val person = mapOf( + "firstName" to "Test", + "lastName" to "de Oliveira", + "email" to "testdeoliveira@kotlintest.com" + ) + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", person)), + ) + val expression = parser.parseString("Mid text expression: @{sds.lastName}.") as StringTemplate + expression.initialize(scope) + assertEquals("Mid text expression: de Oliveira.", expression.getValue()) + } + + @Test + fun `should replace binding with an array position`() { + val person = mapOf( + "firstName" to "Test", + "lastName" to "de Oliveira", + "email" to "testdeoliveira@kotlintest.com", + "phones" to listOf("(00) 00000-0000", "(99) 99999-9999") + ) + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", person)), + ) + val expression = parser.parseString("@{sds.phones[1]}") as StateReference + expression.initialize(scope) + assertEquals("(99) 99999-9999", expression.getValue()) + } + + @Test + fun `should not replace binding in the middle of a text with an array position`() { + val array = listOf("one", "two", "three", "four") + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", array)), + ) + val expression = parser.parseString("Mid text expression: @{sds[2]}.") as StringTemplate + expression.initialize(scope) + assertEquals("Mid text expression: three.", expression.getValue()) + } + + @Test + fun `should replace binding in the middle using multiple states`() { + val person = mapOf( + "firstName" to "Test", + "lastName" to "de Oliveira", + "email" to "testdeoliveira@kotlintest.com" + ) + + val product = mapOf( + "productName" to "Test Object", + "price" to 133.7, + "description" to "Product for testing" + ) + + val sport = mapOf( + "sportName" to "Basketball", + "whatYouUse" to "Ball" + ) + + val scope = StateOnlyScope( + parent = nimbus, + states = listOf( + ServerDrivenState("person", person), + ServerDrivenState("product", product), + ServerDrivenState("sport", sport), + ), + ) + + val expression = parser.parseString("Mid text expression: @{product.price}.") as StringTemplate + expression.initialize(scope) + assertEquals("Mid text expression: 133.7.", expression.getValue()) + } + + @Test + fun `should replace with empty string if no state is found on a string interpolation`() { + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds2", "Hello World")), + ) + val expression = parser.parseString("Mid text expression: @{sds}.") as StringTemplate + expression.initialize(scope) + assertEquals("Mid text expression: .", expression.getValue()) + } + + @Test + fun `should replace with null if no state is found`() { + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds2", "Hello World")), + ) + val expression = parser.parseString("@{sds}") as StateReference + expression.initialize(scope) + assertEquals(null, expression.getValue()) + } + + @Test + fun `should not replace if path does not exist in the referred state`() { + val person = mapOf( + "firstName" to "Test", + "lastName" to "de Oliveira", + "email" to "testdeoliveira@kotlintest.com" + ) + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", person)), + ) + val expression = parser.parseString("@{sds.description}") as StateReference + expression.initialize(scope) + assertEquals(null, expression.getValue()) + } + + @Test + fun `should escape expression`() { + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", "Hello World")), + ) + val expression = parser.parseString("\\@{sds}") as StringTemplate + expression.initialize(scope) + assertEquals("@{sds}", expression.getValue()) + } + + @Test + fun `should not escape expression when slash is also escaped`() { + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", "Hello World")), + ) + val expression = parser.parseString("\\\\@{sds}") as StringTemplate + expression.initialize(scope) + assertEquals("\\Hello World", expression.getValue()) + } + + @Test + fun `should not escape expression when a escaped slash is present but another slash is also present`() { + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("sds", "Hello World")), + ) + val expression = parser.parseString("\\\\\\@{sds}") as StringTemplate + expression.initialize(scope) + assertEquals("\\@{sds}", expression.getValue()) + } + + // #Literals + @Test + fun `should resolve literals`() { + var expression = parser.parseString("@{true}") + assertEquals(true, expression.getValue()) + + expression = parser.parseString("@{false}") + assertEquals(false, expression.getValue()) + + expression = parser.parseString("@{null}") + assertEquals(null, expression.getValue()) + + expression = parser.parseString("@{10}") + assertEquals(10, expression.getValue()) + + expression = parser.parseString("@{'true'}") + assertEquals("true", expression.getValue()) + + expression = parser.parseString("@{'hello world, this is { beagle }!'}") + assertEquals("hello world, this is { beagle }!", expression.getValue()) + } + + @Test + fun `should escape string`() { + val expression = parser.parseString("@{'hello \\'world\\'!'}") + assertEquals("hello 'world'!", expression.getValue()) + } + + @Test + fun `should keep control symbols`() { + val expression = parser.parseString("@{'hello\nworld!'}") + assertEquals("hello\nworld!", expression.getValue()) + } + + @Test + fun `should do nothing for a malformed string`() { + val expression = parser.parseString("@{\'test}") as StringTemplate + expression.initialize(nimbus) + assertEquals("@{\'test}", expression.getValue()) + } + + @Test + fun `should treat malformed number as a context id`() { + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("5ao1", "test")), + ) + val expression = parser.parseString("@{5ao1}") as StateReference + expression.initialize(scope) + assertEquals("test", expression.getValue()) + } + + @Test + fun `should return null for a malformed number and an invalid context id`() { + val scope = StateOnlyScope( + parent = nimbus, + states = listOf(ServerDrivenState("58.72.98", "test")), + ) + val expression = parser.parseString("@{58.72.98}") as StateReference + expression.initialize(scope) + assertEquals(null, expression.getValue()) + } + + // Operations + + @Test + fun `should evaluate correctly an operation`() { + var expression = parser.parseString("@{and(eq(1,1), gt(2,1), lt(2,3))}") as Operation + expression.initialize(nimbus) + assertTrue(expression.getValue() as Boolean) + expression = parser.parseString("@{and(eq(1,1), gt(2,4), lt(2,3))}") as Operation + expression.initialize(nimbus) + assertFalse(expression.getValue() as Boolean) + } +} + diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/network/DefaultHttpClient.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/network/DefaultHttpClient.kt index 518c69f..0571ac5 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/network/DefaultHttpClient.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/network/DefaultHttpClient.kt @@ -53,18 +53,14 @@ class DefaultHttpClientTest { fun `should be able to use a custom http client on the nimbus instance`() = runTest { val nimbus = Nimbus( ServerDrivenConfig( - "/", - "test", - null, - null, - null, - null, - null, - TestCustomHttpClient() + baseUrl = "/", + platform = "test", + httpClient = TestCustomHttpClient() ) ) - val response = nimbus.httpClient.sendRequest(ServerDrivenRequest("/", null, null, null)) + val response = nimbus.httpClient + .sendRequest(ServerDrivenRequest("/", null, null, null)) assertEquals(response.status, TestCustomHttpClient.expectedStatusCode) assertEquals(response.body, TestCustomHttpClient.expectedBody) assertEquals(response.headers, TestCustomHttpClient.expectedHeaders) diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/network/UrlBuilder.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/network/UrlBuilder.kt index 46bb6b4..b2ed2ed 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/network/UrlBuilder.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/network/UrlBuilder.kt @@ -29,13 +29,9 @@ class UrlBuilderTest { fun `should use the custom url builder when nimbus was instantiated with one`() { val nimbus = Nimbus( ServerDrivenConfig( - "/", - "test", - null, - null, - null, - null, - CustomUrlBuilderTest() + baseUrl = "/", + platform = "test", + urlBuilder = { CustomUrlBuilderTest() } ) ) val result = nimbus.urlBuilder.build("new-path") diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/and.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/and.kt index ea8f2af..99977cf 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/and.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/and.kt @@ -1,11 +1,11 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getLogicOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -private val and = getLogicOperations()["and"]!! +private val and = coreUILibrary.getOperation("and")!! class AndOperationTest { private val x = 2 diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/capitalize.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/capitalize.kt index 8dc206b..3e671d7 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/capitalize.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/capitalize.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getStringOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertEquals -private val capitalize = getStringOperations()["capitalize"]!! +private val capitalize = coreUILibrary.getOperation("capitalize")!! class CapitalizeOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/concat.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/concat.kt index a8a3c2b..0856214 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/concat.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/concat.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getOtherOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertEquals -private val concat = getOtherOperations()["concat"]!! +private val concat = coreUILibrary.getOperation("concat")!! class ConcatOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/condition.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/condition.kt index e567093..21eb52d 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/condition.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/condition.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getLogicOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertEquals -private val condition = getLogicOperations()["condition"]!! +private val condition = coreUILibrary.getOperation("condition")!! class ConditionOperationTest { private val x = 0 diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/contains.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/contains.kt index b920a4a..7194558 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/contains.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/contains.kt @@ -1,11 +1,11 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getOtherOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -private val contains = getOtherOperations()["contains"]!! +private val contains = coreUILibrary.getOperation("contains")!! class ContainsOperationTest { private val list = listOf("one", "two", "three") diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/divide.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/divide.kt index 048d973..6016d54 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/divide.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/divide.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getNumberOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertEquals -private val divide = getNumberOperations()["divide"]!! +private val divide = coreUILibrary.getOperation("divide")!! class DivideOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/eq.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/eq.kt index c0c0de8..840ea4f 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/eq.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/eq.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getOtherOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertTrue -private val eq = getOtherOperations()["eq"]!! +private val eq = coreUILibrary.getOperation("eq")!! class EqOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/gt.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/gt.kt index d995c96..3f4c1c2 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/gt.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/gt.kt @@ -1,11 +1,11 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getNumberOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -private val gt = getNumberOperations()["gt"]!! +private val gt = coreUILibrary.getOperation("gt")!! class GtOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/gte.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/gte.kt index 9851913..e9ad148 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/gte.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/gte.kt @@ -1,11 +1,11 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getNumberOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -private val gte = getNumberOperations()["gte"]!! +private val gte = coreUILibrary.getOperation("gte")!! class GteOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/insert.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/insert.kt index 6b0048f..71d0d8e 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/insert.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/insert.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getArrayOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertEquals -private val insert = getArrayOperations()["insert"]!! +private val insert = coreUILibrary.getOperation("insert")!! class InsertOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/isEmpty.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/isEmpty.kt index a954b31..2e8b00b 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/isEmpty.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/isEmpty.kt @@ -1,11 +1,11 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getOtherOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -private val isEmpty = getOtherOperations()["isEmpty"]!! +private val isEmpty = coreUILibrary.getOperation("isEmpty")!! class IsEmptyOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/isNull.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/isNull.kt index aa2cca7..1b761c7 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/isNull.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/isNull.kt @@ -1,11 +1,11 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getOtherOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -private val isNull = getOtherOperations()["isNull"]!! +private val isNull = coreUILibrary.getOperation("isNull")!! class IsNullOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/length.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/length.kt index 39b1f25..80ebabc 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/length.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/length.kt @@ -1,11 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getOtherOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertTrue -private val length = getOtherOperations()["length"]!! +private val length = coreUILibrary.getOperation("length")!! class LengthOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/lowercase.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/lowercase.kt index 3b7eeaa..575d4bf 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/lowercase.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/lowercase.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getStringOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertEquals -private val lowercase = getStringOperations()["lowercase"]!! +private val lowercase = coreUILibrary.getOperation("lowercase")!! class LowercaseOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/lt.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/lt.kt index 47b1d35..f61e7f5 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/lt.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/lt.kt @@ -1,11 +1,11 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getNumberOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -private val lt = getNumberOperations()["lt"]!! +private val lt = coreUILibrary.getOperation("lt")!! class LtOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/lte.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/lte.kt index eae0000..e5e5e96 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/lte.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/lte.kt @@ -1,11 +1,11 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getNumberOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -private val lte = getNumberOperations()["lte"]!! +private val lte = coreUILibrary.getOperation("lte")!! class LteOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/match.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/match.kt index 1bde0c9..c961dda 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/match.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/match.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getStringOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertTrue -private val match = getStringOperations()["match"]!! +private val match = coreUILibrary.getOperation("match")!! class MatchOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/multiply.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/multiply.kt index 443e0a0..33aa472 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/multiply.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/multiply.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getNumberOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertEquals -private val multiply = getNumberOperations()["multiply"]!! +private val multiply = coreUILibrary.getOperation("multiply")!! class MultiplyOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/not.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/not.kt index 92fa20a..a83572c 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/not.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/not.kt @@ -1,11 +1,11 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getLogicOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -private val not = getLogicOperations()["not"]!! +private val not = coreUILibrary.getOperation("not")!! class NotOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/or.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/or.kt index 1ba11a1..37a6e8d 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/or.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/or.kt @@ -1,11 +1,11 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getLogicOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -private val or = getLogicOperations()["or"]!! +private val or = coreUILibrary.getOperation("or")!! class OrOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/remove.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/remove.kt index fbc8917..30ab6e1 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/remove.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/remove.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getArrayOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertEquals -private val remove = getArrayOperations()["remove"]!! +private val remove = coreUILibrary.getOperation("remove")!! class RemoveOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/removeIndex.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/removeIndex.kt index c88a17c..8e9ffb8 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/removeIndex.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/removeIndex.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getArrayOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertEquals -private val removeIndex = getArrayOperations()["removeIndex"]!! +private val removeIndex = coreUILibrary.getOperation("removeIndex")!! class RemoveIndexOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/replace.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/replace.kt index 5570192..1e932a2 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/replace.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/replace.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getStringOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertEquals -private val replace = getStringOperations()["replace"]!! +private val replace = coreUILibrary.getOperation("replace")!! class ReplaceOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/substr.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/substr.kt index 9d4d661..6f8807f 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/substr.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/substr.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getStringOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertEquals -private val substr = getStringOperations()["substr"]!! +private val substr = coreUILibrary.getOperation("substr")!! class SubstrOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/subtract.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/subtract.kt index ca2aa21..307758a 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/subtract.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/subtract.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getNumberOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertEquals -private val subtract = getNumberOperations()["subtract"]!! +private val subtract = coreUILibrary.getOperation("subtract")!! class SubtractOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/sum.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/sum.kt index e6f9e55..d55bd1c 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/sum.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/sum.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getNumberOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertEquals -private val sum = getNumberOperations()["sum"]!! +private val sum = coreUILibrary.getOperation("sum")!! class SumOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/uppercase.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/uppercase.kt index c86a0c5..f483c24 100644 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/uppercase.kt +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/operations/uppercase.kt @@ -1,10 +1,10 @@ package com.zup.nimbus.core.unity.operations -import com.zup.nimbus.core.operations.getStringOperations +import com.zup.nimbus.core.ui.coreUILibrary import kotlin.test.Test import kotlin.test.assertEquals -private val uppercase = getStringOperations()["uppercase"]!! +private val uppercase = coreUILibrary.getOperation("uppercase")!! class UppercaseOperationTest { @Test diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/render/expression.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/render/expression.kt deleted file mode 100644 index f197bcf..0000000 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/render/expression.kt +++ /dev/null @@ -1,1053 +0,0 @@ -package com.zup.nimbus.core.unity.render - -import com.zup.nimbus.core.log.DefaultLogger -import com.zup.nimbus.core.operations.getDefaultOperations -import com.zup.nimbus.core.render.containsExpression -import com.zup.nimbus.core.render.resolveExpressions -import com.zup.nimbus.core.tree.RenderNode -import com.zup.nimbus.core.tree.ServerDrivenState -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class ExpressionTest { - // #Tests for function "containsExpression" - @Test - fun `should find expression inside a text`() { - val result = containsExpression("This is a text with an @{expression} inside of the text!") - assertEquals(true, result) - } - - @Test - fun `should find expression when expression is whole text`() { - val result = containsExpression("@{expression}") - assertEquals(true, result) - } - - @Test - fun `should not find expression when there is no expression`() { - val result = containsExpression("This is a text with no expression inside of the text!") - assertEquals(false, result) - } - - // #Tests for function "resolve" - private val defaultLogger = DefaultLogger() - private val emptyStateHierarchy = emptyList() - private val defaultRenderNode = RenderNode( - "myNode", - "container", - null, - null, - null, - null, - null, - null, - null, - null, - ) - - // ##State Bindings - @Test - fun `should replace by state string`() { - val expectedResult = "Hello World!" - val stateHierarchy = listOf(ServerDrivenState("sds", expectedResult, defaultRenderNode)) - val result = resolveExpressions("@{sds}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals(expectedResult, result) - } - - @Test - fun `should replace by state number`() { - val expectedResult = 584 - val stateHierarchy = listOf(ServerDrivenState("sds", expectedResult, defaultRenderNode)) - val result = resolveExpressions("@{sds}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals(expectedResult, result) - } - - @Test - fun `should replace by state float number`() { - val expectedResult = 584.73 - val stateHierarchy = listOf(ServerDrivenState("sds", expectedResult, defaultRenderNode)) - val result = resolveExpressions("@{sds}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals(result, expectedResult) - } - - @Test - fun `should replace by state boolean`() { - val expectedResult = true - val stateHierarchy = listOf(ServerDrivenState("sds", expectedResult, defaultRenderNode)) - val result = resolveExpressions("@{sds}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals(result, expectedResult) - } - - @Test - fun `should replace by state array`() { - val expectedResult = listOf(1, 2, 3, 4) - val stateHierarchy = listOf(ServerDrivenState("sds", expectedResult, defaultRenderNode)) - val result = resolveExpressions("@{sds}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals(expectedResult, result) - } - - @Test - fun `should replace by state object`() { - val expectedResult = mapOf( - "firstName" to "Test", - "lastName" to "de Oliveira", - "email" to "testdeoliveira@kotlintest.com" - ) - - val stateHierarchy = listOf(ServerDrivenState("sds", expectedResult, defaultRenderNode)) - val result = resolveExpressions("@{sds}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals(expectedResult, result) - } - - @Test - fun `should replace binding in the middle of a text string`() { - val stateHierarchy = listOf(ServerDrivenState("sds", "Hello World", defaultRenderNode)) - val result = resolveExpressions( - "Mid text expression: @{sds}.", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("Mid text expression: Hello World.", result) - } - - @Test - fun `should replace binding in the middle of a text number`() { - val stateHierarchy = listOf(ServerDrivenState("sds", 584, defaultRenderNode)) - val result = resolveExpressions( - "Mid text expression: @{sds}.", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("Mid text expression: 584.", result) - } - - @Test - fun `should replace binding in the middle of a text boolean`() { - val stateHierarchy = listOf(ServerDrivenState("sds", true, defaultRenderNode)) - val result = resolveExpressions( - "Mid text expression: @{sds}.", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("Mid text expression: true.", result) - } - - @Test - fun `should replace binding in the middle of a text array as string`() { - val stateValue = listOf(1, 2, 3, 4) - val stateHierarchy = listOf(ServerDrivenState("sds", stateValue, defaultRenderNode)) - val result = resolveExpressions( - "Mid text expression: @{sds}.", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("Mid text expression: ${stateValue}.", result) - } - - @Test - fun `should replace binding in the middle of a text object as string`() { - val person = mapOf( - "firstName" to "Test", - "lastName" to "de Oliveira", - "email" to "testdeoliveira@kotlintest.com" - ) - - val stateHierarchy = listOf(ServerDrivenState("sds", person, defaultRenderNode)) - val result = resolveExpressions( - "Mid text expression: @{sds}.", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("Mid text expression: $person.", result) - } - - @Test - fun `should replace binding in the middle of a text object key`() { - val person = mapOf( - "firstName" to "Test", - "lastName" to "de Oliveira", - "email" to "testdeoliveira@kotlintest.com" - ) - - val stateHierarchy = listOf(ServerDrivenState("sds", person, defaultRenderNode)) - val result = resolveExpressions( - "Mid text expression: @{sds.lastName}.", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("Mid text expression: de Oliveira.", result) - } - - @Test - fun `should replace binding with an array position`() { - val person = mapOf( - "firstName" to "Test", - "lastName" to "de Oliveira", - "email" to "testdeoliveira@kotlintest.com", - "phones" to listOf("(00) 00000-0000", "(99) 99999-9999") - ) - - val stateHierarchy = listOf(ServerDrivenState("sds", person, defaultRenderNode)) - val result = resolveExpressions("@{sds.phones[1]}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals("(99) 99999-9999", result) - } - - @Test - fun `should not replace binding in the middle of a text with an array position`() { - val array = listOf("one", "two", "three", "four") - val stateHierarchy = listOf(ServerDrivenState("sds", array, defaultRenderNode)) - val result = resolveExpressions( - "Mid text expression: @{sds[2]}.", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("Mid text expression: three.", result) - } - - @Test - fun `should replace binding in the middle using multiple states`() { - val person = mapOf( - "firstName" to "Test", - "lastName" to "de Oliveira", - "email" to "testdeoliveira@kotlintest.com" - ) - - val product = mapOf( - "productName" to "Test Object", - "price" to 133.7, - "description" to "Product for testing" - ) - - val sport = mapOf( - "sportName" to "Basketball", - "whatYouUse" to "Ball" - ) - - val stateHierarchy = listOf( - ServerDrivenState("person", person, defaultRenderNode), - ServerDrivenState("product", product, defaultRenderNode), - ServerDrivenState("sport", sport, defaultRenderNode), - ) - - val result = resolveExpressions( - "Mid text expression: @{product.price}.", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("Mid text expression: 133.7.", result) - } - - @Test - fun `should replace with empty string if no state is found on a string interpolation`() { - val stateHierarchy = listOf(ServerDrivenState("sds2", "Hello World", defaultRenderNode)) - val result = resolveExpressions( - "Mid text expression: @{sds}.", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("Mid text expression: .", result) - } - - @Test - fun `should replace with null if no state is found`() { - val stateHierarchy = listOf(ServerDrivenState("sds2", "Hello World", defaultRenderNode)) - val result = resolveExpressions("@{sds}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals(null, result) - } - - @Test - fun `should not replace if path does not exist in the referred state`() { - val person = mapOf( - "firstName" to "Test", - "lastName" to "de Oliveira", - "email" to "testdeoliveira@kotlintest.com" - ) - - val stateHierarchy = listOf(ServerDrivenState("sds", person, defaultRenderNode)) - val result = resolveExpressions("@{sds.description}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals(null, result) - } - - @Test - fun `should escape expression`() { - val stateHierarchy = listOf(ServerDrivenState("sds", "Hello World", defaultRenderNode)) - val result = resolveExpressions("\\@{sds}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals("@{sds}", result) - } - - @Test - fun `should not escape expression when slash is also escaped`() { - val stateHierarchy = listOf(ServerDrivenState("sds", "Hello World", defaultRenderNode)) - val result = resolveExpressions("\\\\@{sds}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals("\\Hello World", result) - } - - @Test - fun `should not escape expression when a escaped slash is present but a nother slash is also present`() { - val stateHierarchy = listOf(ServerDrivenState("sds", "Hello World", defaultRenderNode)) - val result = resolveExpressions("\\\\\\@{sds}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals("\\@{sds}", result) - } - - // #Literals - @Test - fun `should resolve literals`() { - val stateHierarchy = listOf() - - var result = resolveExpressions("@{true}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals(true, result) - - result = resolveExpressions("@{false}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals(false, result) - - result = resolveExpressions("@{null}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals(null, result) - - result = resolveExpressions("@{10}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals(10, result) - - result = resolveExpressions("@{'true'}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals("true", result) - - result = resolveExpressions( - "@{'hello world, this is { beagle }!'}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("hello world, this is { beagle }!", result) - } - - @Test - fun `should escape string`() { - val result = resolveExpressions("@{'hello \\'world\\'!'}", listOf(), getDefaultOperations(), defaultLogger) - assertEquals("hello 'world'!", result) - } - - @Test - fun `should keep control symbols`() { - val result = resolveExpressions("@{'hello\nworld!'}", listOf(), getDefaultOperations(), defaultLogger) - assertEquals("hello\nworld!", result) - } - - @Test - fun `should do nothing for a malformed string`() { - val result = resolveExpressions("@{\'test}", listOf(), getDefaultOperations(), defaultLogger) - assertEquals("@{\'test}", result) - } - - @Test - fun `should treat malformed number as a context id`() { - val stateHierarchy = listOf(ServerDrivenState("5ao1", "test", defaultRenderNode)) - val result = resolveExpressions("@{5ao1}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals("test", result) - } - - @Test - fun `should return null for a malformed number and an invalid context id`() { - val stateHierarchy = listOf(ServerDrivenState("58.72.98", "test", defaultRenderNode)) - val result = resolveExpressions("@{58.72.98}", stateHierarchy, getDefaultOperations(), defaultLogger) - assertEquals(null, result) - } - - // Operations - - @Test - fun `should evaluate correctly the and operation`() { - var result = resolveExpressions( - "@{and(eq(1,1), gt(2,1), lt(2,3))}", - emptyStateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - result = resolveExpressions( - "@{and(eq(1,1), gt(2,4), lt(2,3))}", - emptyStateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertFalse { result as Boolean } - } - - @Test - fun `should evaluate correctly the capitalize operation`() { - var stateHierarchy = listOf(ServerDrivenState("sds", "test expression", defaultRenderNode)) - var result = resolveExpressions( - "@{capitalize(sds)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("Test expression", result as String) - - stateHierarchy = listOf(ServerDrivenState("sds", "test expression With other letters", defaultRenderNode)) - result = resolveExpressions( - "@{capitalize(sds)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("Test expression With other letters", result as String) - } - - @Test - fun `should evaluate correctly the concat operation for strings`() { - val stateHierarchy = listOf(ServerDrivenState("sds", listOf("one", "-two-", "three"), defaultRenderNode)) - val result = resolveExpressions( - "@{concat(sds[0], sds[1], sds[2])}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("one-two-three", result as String) - } - - @Test - fun `should evaluate correctly the condition operation`() { - val stateHierarchy = listOf( - ServerDrivenState( - "sds", - mapOf( - "number" to 1, - "valid" to "this is a valid value", - "invalid" to 13.14 - ), - defaultRenderNode - ) - ) - var result = resolveExpressions( - "@{condition(eq(sds.number, 1), sds.valid, sds.invalid)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("this is a valid value", result as String) - - result = resolveExpressions( - "@{condition(eq(sds.number, 2), sds.valid, sds.invalid)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals(13.14, result as Double) - - result = resolveExpressions( - "@{condition(true, 'valid value', 'fail')}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("valid value", result as String) - - result = resolveExpressions( - "@{condition(false, 'valid value', 'fail')}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("fail", result as String) - } - - @Test - fun `should evaluate correctly the contains operation`() { - val stateHierarchy = listOf(ServerDrivenState("sds", listOf("one", "-two-", "three"), defaultRenderNode)) - var result = resolveExpressions( - "@{contains(sds, '-two-')}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - result = resolveExpressions( - "@{contains(sds, 'four')}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertFalse { result as Boolean } - } - - @Test - fun `should evaluate correctly the divide operation`() { - val result = resolveExpressions( - "@{divide(16, 2, 2)}", - emptyList(), - getDefaultOperations(), - defaultLogger - ) - assertEquals(4, result as Number) - } - - @Test - fun `should evaluate correctly the eq operation`() { - val stateHierarchy = listOf(ServerDrivenState("sds", 12, defaultRenderNode)) - var result = resolveExpressions( - "@{eq(sds, 12)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - result = resolveExpressions( - "@{eq('exp', 'exp')}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - result = resolveExpressions( - "@{eq('exp', 'xp')}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertFalse { result as Boolean } - } - - @Test - fun `should evaluate correctly the gt operation`() { - val stateHierarchy = listOf(ServerDrivenState("sds", 16, defaultRenderNode)) - var result = resolveExpressions( - "@{gt(sds, 14)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - result = resolveExpressions( - "@{gt(sds, 18)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertFalse { result as Boolean } - - result = resolveExpressions( - "@{gt(18, 14)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - } - - @Test - fun `should evaluate correctly the gte operation`() { - val stateHierarchy = listOf(ServerDrivenState("sds", 16, defaultRenderNode)) - var result = resolveExpressions( - "@{gte(sds, 14)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - result = resolveExpressions( - "@{gte(sds, 18)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertFalse { result as Boolean } - - result = resolveExpressions( - "@{gte(18, 14)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - result = resolveExpressions( - "@{gte(sds, 16)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - result = resolveExpressions( - "@{gte(16, 16)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - } - - @Test - fun `should evaluate correctly the insert operation`() { - val stateHierarchy = listOf(ServerDrivenState("sds", listOf("one", "-two-", "three"), defaultRenderNode)) - var result = resolveExpressions( - "@{insert(sds, 'four')}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals(4, (result as MutableList).size) - assertEquals("four", result[3]) - - result = resolveExpressions( - "@{insert(sds, 'four', 1)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals(4, (result as MutableList).size) - assertEquals("one", result[0]) - assertEquals("four", result[1]) - assertEquals("-two-", result[2]) - assertEquals("three", result[3]) - } - - @Test - fun `should evaluate correctly the isEmpty operation`() { - var stateHierarchy = listOf(ServerDrivenState("sds", listOf("one", "-two-", "three"), defaultRenderNode)) - var result = resolveExpressions( - "@{isEmpty(sds)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertFalse { result as Boolean } - - stateHierarchy = listOf(ServerDrivenState("sds", listOf(), defaultRenderNode)) - result = resolveExpressions( - "@{isEmpty(sds)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - stateHierarchy = listOf(ServerDrivenState("sds", emptyMap(), defaultRenderNode)) - result = resolveExpressions( - "@{isEmpty(sds)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - stateHierarchy = listOf(ServerDrivenState("sds", mapOf("hello" to "world"), defaultRenderNode)) - result = resolveExpressions( - "@{isEmpty(sds)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertFalse { result as Boolean } - - result = resolveExpressions( - "@{isEmpty('hello world')}", - emptyStateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertFalse { result as Boolean } - - result = resolveExpressions( - "@{isEmpty('')}", - emptyStateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - } - - @Test - fun `should evaluate correctly the isNull operation`() { - var stateHierarchy = listOf(ServerDrivenState("sds", null, defaultRenderNode)) - var result = resolveExpressions( - "@{isNull(sds)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - stateHierarchy = listOf(ServerDrivenState("sds", "", defaultRenderNode)) - result = resolveExpressions( - "@{isNull(sds)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertFalse { result as Boolean } - - result = resolveExpressions( - "@{isNull(null)}", - emptyStateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - } - - @Test - fun `should evaluate correctly the length operation`() { - var stateHierarchy = listOf(ServerDrivenState("sds", listOf("one", "-two-", "three"), defaultRenderNode)) - var result = resolveExpressions( - "@{length(sds)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals(3, result as Int) - - stateHierarchy = listOf(ServerDrivenState("sds", "Hello World", defaultRenderNode)) - result = resolveExpressions( - "@{length(sds)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals(11, result as Int) - - result = resolveExpressions( - "@{length('Hello World')}", - emptyStateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals(11, result as Int) - } - - @Test - fun `should evaluate correctly the lowercase operation`() { - val stateHierarchy = listOf(ServerDrivenState("sds", "This is a TEST", defaultRenderNode)) - var result = resolveExpressions( - "@{lowercase(sds)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("this is a test", result as String) - - result = resolveExpressions( - "@{lowercase('This is a TEST')}", - emptyStateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("this is a test", result as String) - } - - @Test - fun `should evaluate correctly the lt operation`() { - val stateHierarchy = listOf(ServerDrivenState("sds", 16, defaultRenderNode)) - var result = resolveExpressions( - "@{lt(sds, 18)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - result = resolveExpressions( - "@{lt(sds, 14)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertFalse { result as Boolean } - - result = resolveExpressions( - "@{lt(14, 18)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - } - - @Test - fun `should evaluate correctly the lte operation`() { - val stateHierarchy = listOf(ServerDrivenState("sds", 16, defaultRenderNode)) - var result = resolveExpressions( - "@{lte(sds, 18)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - result = resolveExpressions( - "@{lte(sds, 14)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertFalse { result as Boolean } - - result = resolveExpressions( - "@{lte(12, 14)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - result = resolveExpressions( - "@{lte(sds, 16)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - result = resolveExpressions( - "@{lte(16, 16)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - } - - @Test - fun `should evaluate correctly the match operation`() { - val stateHierarchy = listOf( - ServerDrivenState( - "sds", - mapOf( - "text" to "This is a {Test} inside a string text", - "matcher" to """^.*\{.*\}?.*$""" - ), - defaultRenderNode - ) - ) - val result = resolveExpressions( - "@{match(sds.text, sds.matcher)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - } - - @Test - fun `should evaluate correctly the multiply operation`() { - val result = resolveExpressions( - "@{multiply(16, 2, 2)}", - emptyList(), - getDefaultOperations(), - defaultLogger - ) - assertEquals(64, result as Number) - } - - @Test - fun `should evaluate correctly the not operation`() { - var result = resolveExpressions( - "@{not(eq(1,2))}", - emptyStateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - result = resolveExpressions( - "@{not(eq(1,1))}", - emptyStateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertFalse { result as Boolean } - } - - @Test - fun `should evaluate correctly the or operation`() { - var result = resolveExpressions( - "@{or(eq(1,2), gt(2,1))}", - emptyStateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - - result = resolveExpressions( - "@{or(eq(1,2), gt(1,2))}", - emptyStateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertFalse { result as Boolean } - - val stateHierarchy = listOf(ServerDrivenState("sds", listOf(false, false, true), defaultRenderNode)) - result = resolveExpressions( - "@{or(sds[0], sds[1], sds[2])}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertTrue { result as Boolean } - } - - @Test - fun `should evaluate correctly the remove operation`() { - val stateHierarchy = listOf(ServerDrivenState("sds", listOf("one", "two", "three"), defaultRenderNode)) - val result = resolveExpressions( - "@{remove(sds, 'two')}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals(2, (result as MutableList).size) - assertEquals("one", result[0]) - assertEquals("three", result[1]) - } - - @Test - fun `should evaluate correctly the removeIndex operation`() { - val stateHierarchy = listOf(ServerDrivenState("sds", listOf("one", "two", "three"), defaultRenderNode)) - val result = resolveExpressions( - "@{removeIndex(sds, 1)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals(2, (result as MutableList).size) - assertEquals("one", result[0]) - assertEquals("three", result[1]) - } - - @Test - fun `should evaluate correctly the replace operation`() { - val stateHierarchy = listOf( - ServerDrivenState( - "sds", - mapOf( - "text" to "This is a Test text.", - "term" to "This is a", - "new" to "Replaced" - ), - defaultRenderNode - ) - ) - val result = resolveExpressions( - "@{replace(sds.text, sds.term, sds.new)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("Replaced Test text.", result as String) - } - - @Test - fun `should evaluate correctly the substr operation`() { - val stateHierarchy = listOf( - ServerDrivenState( - "sds", - mapOf( - "text" to "This is a Test text.", - "start" to 10, - "end" to 14 - ), - defaultRenderNode - ) - ) - val result = resolveExpressions( - "@{substr(sds.text, sds.start, sds.end)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("Test", result as String) - } - - @Test - fun `should evaluate correctly the subtract operation`() { - val stateHierarchy = listOf(ServerDrivenState("sds", listOf(16, 2, 2), defaultRenderNode)) - var result = resolveExpressions( - "@{subtract(sds[0], sds[1], sds[2])}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals(12, result as Number) - - result = resolveExpressions( - "@{subtract(16, 2, 2)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals(12, result as Number) - } - - @Test - fun `should evaluate correctly the sum operation`() { - val result = resolveExpressions( - "@{sum(16, 2, 2)}", - emptyList(), - getDefaultOperations(), - defaultLogger - ) - assertEquals(20, result as Number) - } - - @Test - fun `should evaluate correctly the concat operation for arrays`() { - val stateHierarchy = listOf( - ServerDrivenState( - "sds", - mapOf( - "first" to listOf("one", "two", "three"), - "second" to listOf("four", "five", "six"), - ), - defaultRenderNode - ) - ) - val result = resolveExpressions( - "@{concat(sds.first, sds.second)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals(6, (result as List).size) - assertEquals("one", result[0]) - assertEquals("two", result[1]) - assertEquals("three", result[2]) - assertEquals("four", result[3]) - assertEquals("five", result[4]) - assertEquals("six", result[5]) - } - - @Test - fun `should evaluate correctly the uppercase operation`() { - val stateHierarchy = listOf(ServerDrivenState("sds", "This is a TEST", defaultRenderNode)) - var result = resolveExpressions( - "@{uppercase(sds)}", - stateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("THIS IS A TEST", result as String) - - result = resolveExpressions( - "@{uppercase('This is a TEST')}", - emptyStateHierarchy, - getDefaultOperations(), - defaultLogger - ) - assertEquals("THIS IS A TEST", result as String) - } -} diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/tree/NodeBuilder.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/tree/NodeBuilder.kt new file mode 100644 index 0000000..724ea59 --- /dev/null +++ b/src/commonTest/kotlin/com.zup.nimbus.core/unity/tree/NodeBuilder.kt @@ -0,0 +1,22 @@ +package com.zup.nimbus.core.unity.tree + +import com.zup.nimbus.core.Nimbus +import com.zup.nimbus.core.ServerDrivenConfig +import com.zup.nimbus.core.tree.dynamic.builder.MalformedJsonError +import kotlin.test.Test +import kotlin.test.assertTrue + +class NodeBuilderTest { + val nimbus = Nimbus(ServerDrivenConfig(baseUrl = "", platform = "test")) + + @Test + fun `should throw when json is invalid`() { + var error: Throwable? = null + try { + nimbus.nodeBuilder.buildFromJsonString("""{ "aa": 45, 85,""") + } catch (e: Throwable) { + error = e + } + assertTrue(error is MalformedJsonError) + } +} diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/tree/ObservableStateTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/tree/ObservableStateTest.kt deleted file mode 100644 index 5d775ea..0000000 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/tree/ObservableStateTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.zup.nimbus.core.unity.tree - -import com.zup.nimbus.core.tree.ObservableState -import kotlin.test.Test -import kotlin.test.assertEquals - -class ObservableStateTest { - @Test - fun `should create a primitive typed state with a determined id`() { - val stateId = "testState" - val stateValue = "Test state value" - val state = ObservableState(stateId, stateValue) - - assertEquals(stateId, state.id) - assertEquals(stateValue, state.value) - } - - @Test - fun `should update the value of a primitive typed state`() { - val stateId = "testState" - val stateValue = "Test state value" - val stateUpdatedValue = "This is the updated test state value" - val state = ObservableState(stateId, stateValue) - - assertEquals(stateValue, state.value) - state.set(stateUpdatedValue, "") - assertEquals(stateUpdatedValue, state.value) - } - - @Test - fun `should update the value of a primitive typed state and notify the listeners with the new state value`() { - val stateId = "testState" - val stateValue = "Test state value" - val stateUpdatedValue = "This is the updated test state value" - val state = ObservableState(stateId, stateValue) - state.onChange { assertEquals(stateUpdatedValue, it) } - - assertEquals(stateValue, state.value) - state.set(stateUpdatedValue, "") - assertEquals(stateUpdatedValue, state.value) - } - - @Test - fun `should create an object state with a determined id`() { - val stateId = "testState" - val stateValue = mapOf ( - "a" to "foo", - "b" to "bar" - ) - val state = ObservableState(stateId, stateValue) - - assertEquals(stateId, state.id) - assertEquals(stateValue, state.value) - } - - @Test - fun `should update an object's attribute value from the state`() { - val stateId = "testState" - val stateValue = mapOf ( - "a" to "foo", - "b" to "bar" - ) - val state = ObservableState(stateId, stateValue) - assertEquals(stateValue, state.value) - assertEquals("bar", (state.value as Map<*, *>)["b"]) - - state.set("foo bar", "b") - assertEquals("foo bar", (state.value as Map<*, *>)["b"]) - } - - @Test - fun `should update an object's attribute value from the state and notify the listeners with the new state value`() { - val stateId = "testState" - val stateValue = mapOf ("a" to "foo", "b" to "bar") - val state = ObservableState(stateId, stateValue) - state.onChange { - assertEquals((it as Map<*, *>)["a"], "foo") - assertEquals(it["b"], "foo bar") - } - assertEquals(stateValue, state.value) - assertEquals("bar", (state.value as Map<*, *>)["b"]) - - state.set("foo bar", "b") - assertEquals("foo bar", (state.value as Map<*, *>)["b"]) - } - - @Test - fun `should create an list state with a determined id`() { - val stateId = "testState" - val stateValue = listOf ("a", "b", "c") - val state = ObservableState(stateId, stateValue) - - assertEquals(stateId, state.id) - assertEquals(stateValue, state.value) - } - - @Test - fun `should update an list's value from the state`() { - val stateId = "testState" - val stateValue = listOf ("a", "b", "c") - val stateUpdatedValue = listOf ("a", "foo bar", "c") - val state = ObservableState(stateId, stateValue) - assertEquals(stateValue, state.value) - assertEquals("b", (state.value as List<*>)[1]) - - state.set(stateUpdatedValue, "") - assertEquals("a", (state.value as List<*>)[0]) - assertEquals("foo bar", (state.value as List<*>)[1]) - assertEquals("c", (state.value as List<*>)[2]) - } - - @Test - fun `should update an list's value from the state and notify the listeners with the new state value`() { - val stateId = "testState" - val stateValue = listOf ("a", "b", "c") - val stateUpdatedValue = listOf ("a", "foo bar", "c") - val state = ObservableState(stateId, stateValue) - state.onChange { - assertEquals("a", (it as List<*>)[0]) - assertEquals("foo bar", it[1]) - assertEquals("c", it[2]) - } - - assertEquals(stateValue, state.value) - assertEquals("b", (state.value as List<*>)[1]) - - state.set(stateUpdatedValue, "") - assertEquals("a", (state.value as List<*>)[0]) - assertEquals("foo bar", (state.value as List<*>)[1]) - assertEquals("c", (state.value as List<*>)[2]) - } -} diff --git a/src/commonTest/kotlin/com.zup.nimbus.core/unity/tree/RenderNodeTest.kt b/src/commonTest/kotlin/com.zup.nimbus.core/unity/tree/RenderNodeTest.kt deleted file mode 100644 index d8777ae..0000000 --- a/src/commonTest/kotlin/com.zup.nimbus.core/unity/tree/RenderNodeTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.zup.nimbus.core.unity.tree - -import com.zup.nimbus.core.tree.DefaultIdManager -import com.zup.nimbus.core.tree.MalformedJsonError -import com.zup.nimbus.core.tree.RenderNode -import kotlin.test.Test -import kotlin.test.assertTrue - -class RenderNodeTest { - @Test - fun `should throw when json is invalid`() { - var error: Throwable? = null - try { - RenderNode.fromJsonString("""{ "aa": 45, 85,""", DefaultIdManager()) - } catch (e: Throwable) { - error = e - } - assertTrue(error is MalformedJsonError) - } -} diff --git a/src/iosMain/kotlin/com/zup/nimbus/core/regex/FastRegex.kt b/src/iosMain/kotlin/com/zup/nimbus/core/regex/FastRegex.kt index 1e2ef1b..9a05c4c 100644 --- a/src/iosMain/kotlin/com/zup/nimbus/core/regex/FastRegex.kt +++ b/src/iosMain/kotlin/com/zup/nimbus/core/regex/FastRegex.kt @@ -95,19 +95,27 @@ actual class FastRegex actual constructor(actual val pattern: String) { } actual fun replace(input: String, transform: (MatchGroups) -> String): String { + return transform(input, { it }, transform).joinToString("") + } + + actual fun transform( + input: String, + transformUnmatching: (String) -> T, + transformMatching: (MatchGroups) -> T, + ): List { val matches = findAllMatches(input) - val parts = mutableListOf() + val parts = mutableListOf() var next = 0 matches.forEach { val groups = collectGroups(nsStr(input), it) val length = groups.values.first().length val end = NSMaxRange(it.rangeAtIndex(0)).toInt() val start = end - length - parts.add(input.substring(next, start)) - parts.add(transform(groups)) + parts.add(transformUnmatching(input.substring(next, start))) + parts.add(transformMatching(groups)) next = end } - parts.add(input.substring(next)) - return parts.joinToString("") + parts.add(transformUnmatching(input.substring(next))) + return parts } }