diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index f5b364a..a9d438d 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -2,7 +2,7 @@ # yarn lockfile v1 -"@colors/colors@1.5.1": +"@colors/colors@1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== @@ -63,28 +63,12 @@ dependencies: "@types/node" "*" -"@types/eslint-scope@^3.7.3": - version "3.7.7" - resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" - integrity sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg== - dependencies: - "@types/eslint" "*" - "@types/estree" "*" - -"@types/eslint@*": - version "9.6.1" - resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-9.6.1.tgz#d5795ad732ce81715f27f75da913004a56751584" - integrity sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag== - dependencies: - "@types/estree" "*" - "@types/json-schema" "*" - -"@types/estree@*", "@types/estree@^1.0.5": +"@types/estree@^1.0.5": version "1.0.8" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== -"@types/json-schema@*", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": +"@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -665,7 +649,7 @@ engine.io@~6.6.0: engine.io-parser "~5.2.1" ws "~8.17.1" -enhanced-resolve@^5.17.0: +enhanced-resolve@^5.17.1: version "5.18.2" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz#7903c5b32ffd4b2143eeb4b92472bd68effd5464" integrity sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ== @@ -1194,10 +1178,10 @@ karma-webpack@5.0.1: minimatch "^9.0.3" webpack-merge "^4.1.5" -karma@6.4.3: - version "6.4.3" - resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.3.tgz#763e500f99597218bbb536de1a14acc4ceea7ce8" - integrity sha512-LuucC/RE92tJ8mlCwqEoRWXP38UMAqpnq98vktmS9SznSoUPPUJQbc91dHcxcunROvfQjdORVA/YFviH+Xci9Q== +karma@6.4.4: + version "6.4.4" + resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.4.tgz#dfa5a426cf5a8b53b43cd54ef0d0d09742351492" + integrity sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w== dependencies: "@colors/colors" "1.5.0" body-parser "^1.19.0" @@ -1229,6 +1213,13 @@ kind-of@^6.0.2: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== +kotlin-web-helpers@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/kotlin-web-helpers/-/kotlin-web-helpers-2.0.0.tgz#b112096b273c1e733e0b86560998235c09a19286" + integrity sha512-xkVGl60Ygn/zuLkDPx+oHj7jeLR7hCvoNF99nhwXMn8a3ApB4lLiC9pk4ol4NHPjyoCbvQctBqvzUcp8pkqyWw== + dependencies: + format-util "^1.0.5" + loader-runner@^4.2.0: version "4.3.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" @@ -1337,10 +1328,10 @@ mkdirp@^0.5.5: dependencies: minimist "^1.2.6" -mocha@10.7.0: - version "10.7.0" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.7.0.tgz#9e5cbed8fa9b37537a25bd1f7fb4f6fc45458b9a" - integrity sha512-v8/rBWr2VO5YkspYINnvu81inSz2y3ODJrhO175/Exzor1RcEZZkizgE2A+w/CAXXoESS8Kys5E62dOHGHzULA== +mocha@10.7.3: + version "10.7.3" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.7.3.tgz#ae32003cabbd52b59aece17846056a68eb4b0752" + integrity sha512-uQWxAu44wwiACGqjbPYmjo7Lg8sFrS3dQe7PP2FQI+woptP4vZXSMcfMyFL/e1yFEeEpV4RtyTpZROOKmxis+A== dependencies: ansi-colors "^4.1.3" browser-stdout "^1.3.1" @@ -1879,11 +1870,6 @@ type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" -typescript@5.5.4: - version "5.5.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" - integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== - ua-parser-js@^0.7.30: version "0.7.40" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.40.tgz#c87d83b7bb25822ecfa6397a0da5903934ea1562" @@ -1982,12 +1968,11 @@ webpack-sources@^3.2.3: resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.3.tgz#d4bf7f9909675d7a070ff14d0ef2a4f3c982c723" integrity sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg== -webpack@5.93.0: - version "5.93.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.93.0.tgz#2e89ec7035579bdfba9760d26c63ac5c3462a5e5" - integrity sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA== +webpack@5.94.0: + version "5.94.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.94.0.tgz#77a6089c716e7ab90c1c67574a28da518a20970f" + integrity sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg== dependencies: - "@types/eslint-scope" "^3.7.3" "@types/estree" "^1.0.5" "@webassemblyjs/ast" "^1.12.1" "@webassemblyjs/wasm-edit" "^1.12.1" @@ -1996,7 +1981,7 @@ webpack@5.93.0: acorn-import-attributes "^1.9.5" browserslist "^4.21.10" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.17.0" + enhanced-resolve "^5.17.1" es-module-lexer "^1.2.1" eslint-scope "5.1.1" events "^3.2.0" diff --git a/src/commonMain/kotlin/com/lightningkite/reactive/context/ReactiveContext.kt b/src/commonMain/kotlin/com/lightningkite/reactive/context/ReactiveContext.kt index 7c039c6..60cfce0 100644 --- a/src/commonMain/kotlin/com/lightningkite/reactive/context/ReactiveContext.kt +++ b/src/commonMain/kotlin/com/lightningkite/reactive/context/ReactiveContext.kt @@ -9,6 +9,7 @@ import com.lightningkite.reactive.core.Listenable import com.lightningkite.reactive.core.Reactive import com.lightningkite.reactive.core.ReactiveState import com.lightningkite.reactive.core.reactiveState +import com.lightningkite.reactive.lensing.validation.IssueNode import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow diff --git a/src/commonMain/kotlin/com/lightningkite/reactive/extensions/helpers.kt b/src/commonMain/kotlin/com/lightningkite/reactive/extensions/helpers.kt index fcc29eb..a683f68 100644 --- a/src/commonMain/kotlin/com/lightningkite/reactive/extensions/helpers.kt +++ b/src/commonMain/kotlin/com/lightningkite/reactive/extensions/helpers.kt @@ -59,10 +59,10 @@ fun Reactive.withWrite(action: suspend Reactive.(T) -> Unit): MutableR } } -fun Reactive.onNextSuccess(action: (T) -> Unit): () -> Unit { +fun Reactive.onNextSuccess(action: (T) -> Unit): (() -> Unit)? { if (state.success) { state.onSuccess(action) - return {} + return null } var remover: (() -> Unit)? = null diff --git a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/LensByElement.kt b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/LensByElement.kt index a86ab53..180ec8e 100644 --- a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/LensByElement.kt +++ b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/LensByElement.kt @@ -7,6 +7,10 @@ import com.lightningkite.reactive.core.MutableWithReactiveValue import com.lightningkite.reactive.core.Reactive import com.lightningkite.reactive.core.ReactiveState import com.lightningkite.reactive.extensions.invokeAllSafe +import com.lightningkite.reactive.lensing.validation.IssueNode +import com.lightningkite.reactive.lensing.validation.MutableValidated +import com.lightningkite.reactive.lensing.validation.Validated +import com.lightningkite.reactive.lensing.validation.ValidatedValue import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -44,7 +48,11 @@ class LensByElement( val identity: (E) -> ID, val elementLens: (LensByElement.Element) -> T ) : Reactive> { - inner class Element internal constructor(valueInit: E) : MutableWithReactiveValue, CalculationContext { + private val node = IssueNode(parent = (source as? MutableValidated)?.node).apply { connect() } + + inner class Element internal constructor(valueInit: E) : MutableWithReactiveValue, MutableValidated, CalculationContext { + override val node: IssueNode = this@LensByElement.node.child() + private var job = Job() private val restOfContext = Dispatchers.Default + CoroutineExceptionHandler { coroutineContext, throwable -> if (throwable !is CancellationException) { @@ -56,6 +64,7 @@ class LensByElement( internal var dead = false set(value) { field = value + if (value) node.disconnect() else node.connect() listeners.invokeAllSafe() job.cancel() job = Job() @@ -63,10 +72,8 @@ class LensByElement( var id: ID = identity(valueInit) private set private val listeners = ArrayList<() -> Unit>() - override val state: ReactiveState - get() = ReactiveState(value) override var value: E = valueInit - set(value) { + internal set(value) { if (field != value) { id = identity(value) field = value diff --git a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/abstracts.kt b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/abstracts.kt index e6e6c54..d19db49 100644 --- a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/abstracts.kt +++ b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/abstracts.kt @@ -42,7 +42,9 @@ open class SetLens( val set: (T) -> O ) : Lens, O, T>(source, get), MutableReactive { override suspend fun set(value: T) { - source.set(set.invoke(value)) + val transformed = set.invoke(value) + state = ReactiveState(get(transformed)) // using get(set(value)) so that echos are filtered out + source.set(transformed) } } @@ -52,7 +54,9 @@ open class ModifyLens( val modify: (O, T) -> O ) : Lens, O, T>(source, get), MutableReactive { override suspend fun set(value: T) { - source.set(modify(source.awaitOnce(), value)) + val transformed = modify(source.awaitOnce(), value) + state = ReactiveState(get(transformed)) // using get(modify(this, value)) so that echos are filtered out + source.set(transformed) } } @@ -90,7 +94,9 @@ open class SetValueLens(source: MutableReactiveValue, get: (O) -> T, va override var value: T get() = super.value set(value) { - source.value = set.invoke(value) + val transformed = set.invoke(value) + super.value = get(transformed) + source.value = transformed } } @@ -99,7 +105,9 @@ open class ModifyValueLens(source: MutableReactiveValue, get: (O) -> T, override var value: T get() = super.value set(value) { - source.value = modify(source.value, value) + val transformed = modify(source.value, value) + super.value = get(transformed) + source.value = transformed } } diff --git a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/IssueNode.kt b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/IssueNode.kt index 2a503f1..95dc699 100644 --- a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/IssueNode.kt +++ b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/IssueNode.kt @@ -1,7 +1,11 @@ package com.lightningkite.reactive.lensing.validation +import com.lightningkite.reactive.context.ReactiveContext +import com.lightningkite.reactive.core.Constant import com.lightningkite.reactive.core.Reactive import com.lightningkite.reactive.core.ReactiveMutableList +import com.lightningkite.reactive.core.ReactiveValue +import com.lightningkite.reactive.core.ResourceUse import com.lightningkite.reactive.core.Signal import com.lightningkite.reactive.core.remember @@ -24,19 +28,69 @@ import com.lightningkite.reactive.core.remember * Reporting an issue on any node will cause that issue to be included in the parent's [issues] list. * Removing a child node will remove its issues from the parent's [issues] list. * + * Note: When created, IssueNodes are not by-default connected to their parent's validation tree. + * This is done to help manage dependencies, and avoid deadlocks in validation. If this wasn't done + * there would be some cases where a validation lens would be discarded, but it's validation issues + * would still propagate up the tree, even though there's no way to clear the issues. To connect an + * IssueNode + * * @property parent The parent node in the validation tree, or null if this is the root. */ -class IssueNode(val parent: IssueNode?) { - private val nodeIssue = Signal(null) +class IssueNode(val parent: IssueNode?) : ResourceUse { + private val nodeIssue = Signal>(Constant(null)) - fun report(issue: Issue?) { nodeIssue.value = issue } + fun report(issue: Issue?) { nodeIssue.value = Constant(issue) } + fun reactiveReport(issue: ReactiveContext.() -> Issue?) { + nodeIssue.value = remember(action = issue) + } private val children = ReactiveMutableList() - fun child() = IssueNode(this).also { children.add(it) } - fun removeChild(child: IssueNode) = children.remove(child) + + /** + * Creates a child of this [IssueNode] and immediately connects it to the validation tree. + * + * Note: This function is **NOT** safe to use in a [ReactiveContext], unless you plan to manage + * the lifetime of the node manually. Using this function in a [ReactiveContext] will create a new + * child every time the context reruns, and will probably leave orphaned nodes that you are unable + * to clear the issues on. + * + * Instead, consider using [child] outside of the [ReactiveContext], and then report to that outside + * node inside any reactive code. + * */ + fun child() = IssueNode(this).apply { connect() } + + private var connected = false + + /** + * Grafts this node and its children to its parent's validation tree. + * This means that this node's issues will propagate to its parent. + * + * Useful for establishing validation dependencies once a set of data has become relevant. + * */ + fun connect() { + if (connected || parent == null) return + connected = true + parent.children.add(this) + } + /** + * Prunes this node and its children from its parent's validation tree. + * This means that this node's issues will no longer propagate to its parent. + * + * Useful for removing validation dependencies on data that is no longer relevant. + * */ + fun disconnect() { + if (!connected || parent == null) return + connected = false + parent.children.remove(this) + } + + override fun beginUse(): () -> Unit { + connect() + return ::disconnect + } val issues : Reactive> = remember { - listOfNotNull(nodeIssue()) + children().flatMap { it.issues() } + listOfNotNull(nodeIssue()()) + children().flatMap { it.issues() } } } diff --git a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/Validated.kt b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/Validated.kt index d9c1f2c..a838710 100644 --- a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/Validated.kt +++ b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/Validated.kt @@ -49,7 +49,7 @@ val IssueTracking.issues get() = node.issues * * Note: Reporting or clearing issues in this node does not affect issues in child nodes. * - * @param issue The issue to report, or null to clear this node's issues. + * @param issue The issue to report, or null to clear this node's issue. */ fun IssueTracking.report(issue: Issue?) = node.report(issue) diff --git a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/entrypoints.kt b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/entrypoints.kt new file mode 100644 index 0000000..fa1c8ae --- /dev/null +++ b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/entrypoints.kt @@ -0,0 +1,82 @@ +package com.lightningkite.reactive.lensing.validation + +import com.lightningkite.reactive.core.Listenable +import com.lightningkite.reactive.core.MutableReactive +import com.lightningkite.reactive.core.MutableReactiveValue +import com.lightningkite.reactive.core.Reactive +import com.lightningkite.reactive.core.ReactiveValue +import kotlin.getValue +import kotlin.setValue + +private open class RootValidated( + private val wrapped: Reactive +) : Validated, Reactive by wrapped { + override val node: IssueNode = IssueNode(null) +} + +private class RootMutableValidated( + private val wrapped: MutableReactive +) : MutableValidated, RootValidated(wrapped) { + override suspend fun set(value: T) = wrapped.set(value) +} + +private open class RootValidatedValue( + private val wrapped: ReactiveValue +) : ValidatedValue, Listenable by wrapped { + override val node: IssueNode = IssueNode(null) + override val value: T get() = wrapped.value +} + +private class RootMutableValidatedValue( + private val wrapped: MutableReactiveValue +) : MutableValidatedValue, RootValidatedValue(wrapped) { + override var value: T by wrapped::value +} + +/** + * Wraps a [Reactive] as a [Validated]. + * + * If the receiver is already [Validated], it is returned as-is. Otherwise, this creates the root of the validation tree for the reactive value. + * This enables validation and issue tracking for the reactive value. See [IssueTracking] for more details about validation trees. + * + * @return A [Validated] instance wrapping the receiver. + * + * @see IssueTracking + */ +fun Reactive.validated(): Validated = this as? Validated ?: RootValidated(this) + +/** + * Wraps a [MutableReactive] as a [MutableValidated]. + * + * If the receiver is already a [MutableValidated], it is returned as-is. Otherwise, this creates the root of the validation tree for the reactive value. + * This enables validation and issue tracking for the reactive value. See [IssueTracking] for more details about validation trees. + * + * @return A [MutableValidated] instance wrapping the receiver. + * + * @see IssueTracking + */ +fun MutableReactive.validated(): MutableValidated = this as? MutableValidated ?: RootMutableValidated(this) + +/** + * Wraps a [ReactiveValue] as a [ValidatedValue]. + * + * If the receiver is already [ValidatedValue], it is returned as-is. Otherwise, this creates the root of the validation tree for the reactive value. + * This enables validation and issue tracking for the reactive value. See [IssueTracking] for more details about validation trees. + * + * @return A [ValidatedValue] instance wrapping the receiver. + * + * @see IssueTracking + */ +fun ReactiveValue.validated(): ValidatedValue = this as? ValidatedValue ?: RootValidatedValue(this) + +/** + * Wraps a [MutableReactiveValue] as a [MutableValidatedValue]. + * + * If the receiver is already a [MutableValidatedValue], it is returned as-is. Otherwise, this creates the root of the validation tree for the reactive value. + * This enables validation and issue tracking for the reactive value. See [IssueTracking] for more details about validation trees. + * + * @return A [MutableValidatedValue] instance wrapping the receiver. + * + * @see IssueTracking + */ +fun MutableReactiveValue.validated(): MutableValidatedValue = this as? MutableValidatedValue ?: RootMutableValidatedValue(this) \ No newline at end of file diff --git a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/helpers.kt b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/helpers.kt index 23b9982..2bd9ffb 100644 --- a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/helpers.kt +++ b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/helpers.kt @@ -1,7 +1,9 @@ package com.lightningkite.reactive.lensing.validation +import com.lightningkite.reactive.context.ReactiveContext import com.lightningkite.reactive.core.MutableReactive import com.lightningkite.reactive.core.MutableReactiveValue +import com.lightningkite.reactive.core.Signal /** * Adds a validation check to this [MutableValidated] instance. @@ -88,7 +90,7 @@ fun MutableValidatedValue.assert( * @param description Detailed description of the issue (defaults to [summary]). * @param setOnIssue If true, issues are reported as [Issue.Warning]; if false, as [Issue.Invalid]. */ -fun MutableValidated.validateNotNull( +fun MutableValidated.assertNotNull( summary: String = "Cannot be blank.", description: String = summary, setOnIssue: Boolean = true @@ -101,33 +103,33 @@ fun MutableValidated.validateNotNull( * @param description Detailed description of the issue (defaults to [summary]). * @param setOnIssue If true, issues are reported as [Issue.Warning]; if false, as [Issue.Invalid]. */ -fun MutableValidatedValue.validateNotNull( +fun MutableValidatedValue.assertNotNull( summary: String = "Cannot be blank.", description: String = summary, setOnIssue: Boolean = true ) = assert(summary, description, setOnIssue) { it != null } /** - * Validates that the value of this [MutableValidated] is not blank (for String values). + * Validates that the value of this [MutableValidated] is not blank. * * @param summary Short description of the issue (defaults to "Cannot be blank."). * @param description Detailed description of the issue (defaults to [summary]). * @param setOnIssue If true, issues are reported as [Issue.Warning]; if false, as [Issue.Invalid]. */ -fun MutableValidated.validateNotBlank( +fun MutableValidated.assertNotBlank( summary: String = "Cannot be blank.", description: String = summary, setOnIssue: Boolean = true ) = assert(summary, description, setOnIssue) { it.isNotBlank() } /** - * Validates that the value of this [MutableValidatedValue] is not blank (for String values). + * Validates that the value of this [MutableValidatedValue] is not blank. * * @param summary Short description of the issue (defaults to "Cannot be blank."). * @param description Detailed description of the issue (defaults to [summary]). * @param setOnIssue If true, issues are reported as [Issue.Warning]; if false, as [Issue.Invalid]. */ -fun MutableValidatedValue.validateNotBlank( +fun MutableValidatedValue.assertNotBlank( summary: String = "Cannot be blank.", description: String = summary, setOnIssue: Boolean = true @@ -218,7 +220,7 @@ fun MutableReactiveValue.assert( * @param description Detailed description of the issue (defaults to [summary]). * @param setOnIssue If true, issues are reported as [Issue.Warning]; if false, as [Issue.Invalid]. */ -fun MutableReactive.validateNotNull( +fun MutableReactive.assertNotNull( summary: String = "Cannot be blank.", description: String = summary, setOnIssue: Boolean = true @@ -231,34 +233,57 @@ fun MutableReactive.validateNotNull( * @param description Detailed description of the issue (defaults to [summary]). * @param setOnIssue If true, issues are reported as [Issue.Warning]; if false, as [Issue.Invalid]. */ -fun MutableReactiveValue.validateNotNull( +fun MutableReactiveValue.assertNotNull( summary: String = "Cannot be blank.", description: String = summary, setOnIssue: Boolean = true ) = assert(summary, description, setOnIssue) { it != null } /** - * Validates that the value of this [MutableReactive] is not blank (for String values). + * Validates that the value of this [MutableReactive] is not blank. * * @param summary Short description of the issue (defaults to "Cannot be blank."). * @param description Detailed description of the issue (defaults to [summary]). * @param setOnIssue If true, issues are reported as [Issue.Warning]; if false, as [Issue.Invalid]. */ -fun MutableReactive.validateNotBlank( +fun MutableReactive.assertNotBlank( summary: String = "Cannot be blank.", description: String = summary, setOnIssue: Boolean = true ) = assert(summary, description, setOnIssue) { it.isNotBlank() } /** - * Validates that the value of this [MutableReactiveValue] is not blank (for String values). + * Validates that the value of this [MutableReactiveValue] is not blank. * * @param summary Short description of the issue (defaults to "Cannot be blank."). * @param description Detailed description of the issue (defaults to [summary]). * @param setOnIssue If true, issues are reported as [Issue.Warning]; if false, as [Issue.Invalid]. */ -fun MutableReactiveValue.validateNotBlank( +fun MutableReactiveValue.assertNotBlank( summary: String = "Cannot be blank.", description: String = summary, setOnIssue: Boolean = true ) = assert(summary, description, setOnIssue) { it.isNotBlank() } + +/** + * Runs the provided validation condition reactively, reporting to a child of this [IssueTracking] node. + * */ +fun IssueTracking.report(issue: ReactiveContext.() -> Issue?) { + val child = IssueNode(parent = node) + child.connect() + child.reactiveReport(issue) +} + +/** + * Runs the provided validation condition reactively, reporting to a child of this [IssueTracking] node. + * */ +fun Validated.validateReactive(issue: ReactiveContext.(T) -> String?) = report { issue(this@validateReactive.invoke())?.let(Issue::Warning) } + +/** + * Asserts the provided condition reactively, constructing and reporting an [Issue.Warning] to a child of this [IssueTracking] node. + * */ +fun Validated.assertReactive( + summary: String, + description: String = summary, + condition: ReactiveContext.(T) -> Boolean +) = report { if (condition(this@assertReactive.invoke())) null else Issue.Warning(summary, description) } \ No newline at end of file diff --git a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/mutableValidationLensing.kt b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/mutableValidationLensing.kt deleted file mode 100644 index 59296d3..0000000 --- a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/mutableValidationLensing.kt +++ /dev/null @@ -1,182 +0,0 @@ -package com.lightningkite.reactive.lensing.validation - -import com.lightningkite.reactive.core.Listenable -import com.lightningkite.reactive.core.MutableReactive -import com.lightningkite.reactive.core.MutableReactiveValue -import com.lightningkite.reactive.core.Reactive -import com.lightningkite.reactive.extensions.onNextSuccess -import com.lightningkite.reactive.lensing.ModifyLens -import com.lightningkite.reactive.lensing.ModifyValueLens -import com.lightningkite.reactive.lensing.SetLens -import com.lightningkite.reactive.lensing.SetValueLens - -class ValidatedSetLens( - source: MutableValidated, - get: (T) -> L, - set: (L) -> T -) : MutableValidated, SetLens(source, get, set) { - override val node: IssueNode = source.node.child() -} - -class ValidatedModifyLens( - source: MutableValidated, - get: (T) -> L, - modify: (T, L) -> T -) : MutableValidated, ModifyLens(source, get, modify) { - override val node: IssueNode = source.node.child() -} - -class ValidatedSetValueLens( - source: MutableValidatedValue, - get: (T) -> L, - set: (L) -> T -) : MutableValidatedValue, SetValueLens(source, get, set) { - override val node: IssueNode = source.node.child() -} - -class ValidatedModifyValueLens( - source: MutableValidatedValue, - get: (T) -> L, - modify: (T, L) -> T -) : MutableValidatedValue, ModifyValueLens(source, get, modify) { - override val node: IssueNode = source.node.child() -} - - -// Named anonymous object -private class ValidationRoot( - val wrapped: MutableReactive -) : MutableValidated, Reactive by wrapped { - override val node: IssueNode = IssueNode(null) - override suspend fun set(value: T) = wrapped.set(value) -} - -private class ValidationLens( - private val source: MutableValidated, - private val validate: (T) -> Issue? -) : MutableValidated, Reactive by source { - override val node: IssueNode = source.node.child() - - private fun check(value: T): Boolean { - val issue = validate(value) - return if (issue == null) { - node.report(null) - true - } - else { - node.report(issue) - issue !is Issue.Invalid - } - } - override suspend fun set(value: T) { - if (check(value)) source.set(value) - } - init { - source.onNextSuccess { check(it) } - } -} - -// Named anonymous object -private class ValidationRootValue( - val wrapped: MutableReactiveValue -) : MutableValidatedValue, Listenable by wrapped { - override val node: IssueNode = IssueNode(null) - override var value: T by wrapped::value -} - -private class ValidationValueLens( - private val source: MutableValidatedValue, - private val validate: (T) -> Issue? -) : MutableValidatedValue, Listenable by source { - override val node: IssueNode = source.node.child() - - private fun check(value: T): Boolean { - val issue = validate(value) - return if (issue == null) { - node.report(null) - true - } - else { - node.report(issue) - issue !is Issue.Invalid - } - } - override var value: T - get() = source.value - set(value) { - if (check(value)) source.value = value - } - init { - check(source.value) - } -} - -/** - * Wraps a [MutableReactive] as a [MutableValidated]. - * - * If the receiver is already a [MutableValidated], it is returned as-is. Otherwise, this creates the root of the validation tree for the reactive value. - * This enables validation and issue tracking for the reactive value. See [IssueTracking] for more details about validation trees. - * - * @return A [MutableValidated] instance wrapping the receiver. - * - * @see IssueTracking - */ -fun MutableReactive.validated(): MutableValidated = - this as? MutableValidated ?: ValidationRoot(this) - -/** - * Adds a validation check to a [MutableValidated] instance. - * - * The [validate] function should return an [Issue] if the value is invalid, or null if valid. - * The returned [MutableValidated] will report issues according to the provided validation function. - * - * @param validate Function that returns an [Issue] or null for valid values. - * @return A [MutableValidated] that tracks issues according to [validate]. - */ -fun MutableValidated.checkForIssue(validate: (T) -> Issue?): MutableValidated = ValidationLens(this, validate) - -/** - * Adds a validation check to a [MutableReactive] instance, returning a [MutableValidated] that tracks issues. - * - * The [validate] function should return an [Issue] if the value is invalid, or null if valid. - * - * @param validate Function that returns an [Issue] or null for valid values. - * @return A [MutableValidated] that tracks issues according to [validate]. - */ -fun MutableReactive.checkForIssue(validate: (T) -> Issue?): MutableValidated = ValidationLens(this.validated(), validate) - - - -/** - * Wraps a [MutableReactiveValue] as a [MutableValidatedValue]. - * - * If the receiver is already a [MutableValidatedValue], it is returned as-is. Otherwise, this creates the root of the validation tree for the reactive value. - * This enables validation and issue tracking for the reactive value. See [IssueTracking] for more details about validation trees. - * - * @return A [MutableValidatedValue] instance wrapping the receiver. - * - * @see IssueTracking - */ -fun MutableReactiveValue.validated(): MutableValidatedValue = - this as? MutableValidatedValue ?: ValidationRootValue(this) - -/** - * Adds a validation check to a [MutableValidatedValue] instance. - * - * The [validate] function should return an [Issue] if the value is invalid, or null if valid. - * The returned [MutableValidatedValue] will report issues according to the provided validation function. - * - * @param validate Function that returns an [Issue] or null for valid values. - * @return A [MutableValidatedValue] that tracks issues according to [validate]. - */ -fun MutableValidatedValue.checkForIssue(validate: (T) -> Issue?): MutableValidatedValue = ValidationValueLens(this, validate) - -/** - * Adds a validation check to a [MutableReactiveValue] instance, returning a [MutableValidatedValue] that tracks issues. - * - * The [validate] function should return an [Issue] if the value is invalid, or null if valid. - * - * @param validate Function that returns an [Issue] or null for valid values. - * @return A [MutableValidatedValue] that tracks issues according to [validate]. - */ -fun MutableReactiveValue.checkForIssue(validate: (T) -> Issue?): MutableValidatedValue = ValidationValueLens(this.validated(), validate) diff --git a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/reactiveValidationLensing.kt b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/reactiveValidationLensing.kt deleted file mode 100644 index b110fb6..0000000 --- a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/reactiveValidationLensing.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.lightningkite.reactive.lensing.validation - -import com.lightningkite.reactive.core.Reactive -import com.lightningkite.reactive.core.ReactiveState -import com.lightningkite.reactive.core.ReactiveValue -import com.lightningkite.reactive.lensing.lens - -private class ValidatedLens( - parent: IssueNode?, - source: Reactive, - val validate: (T) -> Issue? -) : Validated { - private val _node = IssueNode(parent) - - private val lens = source.lens { - node.report(validate(it)) - it - } - - override val node: IssueNode - get() { - lens.state // Getting the state guarantees that the validation state will be updated - return _node - } - - override val state: ReactiveState by lens::state - override fun addListener(listener: () -> Unit): () -> Unit = lens.addListener(listener) -} - -private class ValidatedValueLens( - parent: IssueNode?, - source: ReactiveValue, - val validate: (T) -> Issue? -) : ValidatedValue { - private val _node = IssueNode(parent) - - private val lens = source.lens { - node.report(validate(it)) - it - } - - override val node: IssueNode - get() { - lens.state // Getting the state guarantees that the validation state will be updated - return _node - } - - override val value: T by lens::value - override fun addListener(listener: () -> Unit): () -> Unit = lens.addListener(listener) -} - -fun Reactive.validated(validate: (T) -> Issue?): Validated = ValidatedLens(null, this, validate) -fun ReactiveValue.validated(validate: (T) -> Issue?): ValidatedValue = ValidatedValueLens(null, this, validate) - -fun Validated.validated(validate: (T) -> Issue?): Validated = ValidatedLens(node, this, validate) -fun ValidatedValue.validated(validate: (T) -> Issue?): ValidatedValue = ValidatedValueLens(node, this, validate) diff --git a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/validation.kt b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/validation.kt new file mode 100644 index 0000000..41ad118 --- /dev/null +++ b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/validation.kt @@ -0,0 +1,175 @@ +package com.lightningkite.reactive.lensing.validation + +import com.lightningkite.reactive.core.BaseReactive +import com.lightningkite.reactive.core.BaseReactiveValue +import com.lightningkite.reactive.core.MutableReactive +import com.lightningkite.reactive.core.MutableReactiveValue +import com.lightningkite.reactive.core.Reactive +import com.lightningkite.reactive.core.ReactiveState +import com.lightningkite.reactive.core.Signal +import com.lightningkite.reactive.core.remember +import kotlin.random.Random + +private open class ValidatedLens, T>( + val source: S, + val validate: (T) -> Issue? +) : Validated, BaseReactive(source.state) { + protected val baseNode = IssueNode(parent = source.node) + private var myListen: (() -> Unit)? = null + + private fun check(state: ReactiveState) = + state.onSuccess { value -> + baseNode.report(validate(value)) + } + + override val node: IssueNode + get() = baseNode.also { if (myListen == null) check(source.state) } + + override var state: ReactiveState + get() { + if (myListen == null) super.state = source.state.also(::check) + return super.state + } + set(value) { + super.state = value + } + + private var listen = true + protected inline fun withoutListening(block: () -> Unit) { + listen = false + block() + listen = true + } + + override fun activate() { + super.activate() + baseNode.connect() + state = source.state.also(::check) + myListen = source.addListener { + if (listen) state = source.state.also(::check) + } + } + override fun deactivate() { + super.deactivate() + baseNode.disconnect() + myListen?.invoke() + myListen = null + } +} + +private open class ValidatedValueLens, T>( + val source: S, + val validate: (T) -> Issue? +) : ValidatedValue, BaseReactiveValue(source.value) { + protected val baseNode = IssueNode(parent = source.node) + private var myListen: (() -> Unit)? = null + + private fun check(value: T) = baseNode.report(validate(value)) + + override val node: IssueNode + get() = baseNode.also { if (myListen == null) check(source.value) } + + override var value: T + get() { + if (myListen == null) super.value = source.value.also(::check) + return super.value + } + set(value) { + super.value = value + } + + private var listen = true + protected inline fun withoutListening(block: () -> Unit) { + listen = false + block() + listen = true + } + + override fun activate() { + super.activate() + baseNode.connect() + value = source.value.also(::check) + myListen = source.addListener { + if (listen) value = source.value.also(::check) + } + } + override fun deactivate() { + super.deactivate() + baseNode.disconnect() + myListen?.invoke() + myListen = null + } +} + +private class MutableValidationLens( + source: MutableValidated, + validate: (T) -> Issue? +) : MutableValidated, ValidatedLens, T>(source, validate) { + override suspend fun set(value: T) { + val issue = validate(value) + baseNode.report(issue) + super.state = ReactiveState(value) + if (issue == null || issue is Issue.Warning) withoutListening { source.set(value) } + } +} + +private class ValidationValueLens( + source: MutableValidatedValue, + validate: (T) -> Issue? +) : MutableValidatedValue, ValidatedValueLens, T>(source, validate) { + override var value + get() = super.value + set(value) { + val issue = validate(value) + baseNode.report(issue) + super.value = value + if (issue == null || issue is Issue.Warning) withoutListening { source.value = value } + } +} + +fun Validated.checkForIssue(validate: (T) -> Issue?): Validated = ValidatedLens(this, validate) + +fun ValidatedValue.checkForIssue(validate: (T) -> Issue?): ValidatedValue = ValidatedValueLens(this, validate) + +/** + * Adds a validation check to a [MutableValidated] instance. + * + * The [validate] function should return an [Issue] if the value is invalid, or null if valid. + * The returned [MutableValidated] will report issues according to the provided validation function. + * + * @param validate Function that returns an [Issue] or null for valid values. + * @return A [MutableValidated] that tracks issues according to [validate]. + */ +fun MutableValidated.checkForIssue(validate: (T) -> Issue?): MutableValidated = MutableValidationLens(this, validate) + +/** + * Adds a validation check to a [MutableReactive] instance, returning a [MutableValidated] that tracks issues. + * + * The [validate] function should return an [Issue] if the value is invalid, or null if valid. + * + * @param validate Function that returns an [Issue] or null for valid values. + * @return A [MutableValidated] that tracks issues according to [validate]. + */ +fun MutableReactive.checkForIssue(validate: (T) -> Issue?): MutableValidated = MutableValidationLens(this.validated(), validate) + + +/** + * Adds a validation check to a [MutableValidatedValue] instance. + * + * The [validate] function should return an [Issue] if the value is invalid, or null if valid. + * The returned [MutableValidatedValue] will report issues according to the provided validation function. + * + * @param validate Function that returns an [Issue] or null for valid values. + * @return A [MutableValidatedValue] that tracks issues according to [validate]. + */ +fun MutableValidatedValue.checkForIssue(validate: (T) -> Issue?): MutableValidatedValue = ValidationValueLens(this, validate) + +/** + * Adds a validation check to a [MutableReactiveValue] instance, returning a [MutableValidatedValue] that tracks issues. + * + * The [validate] function should return an [Issue] if the value is invalid, or null if valid. + * + * @param validate Function that returns an [Issue] or null for valid values. + * @return A [MutableValidatedValue] that tracks issues according to [validate]. + */ +fun MutableReactiveValue.checkForIssue(validate: (T) -> Issue?): MutableValidatedValue = ValidationValueLens(this.validated(), validate) diff --git a/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/validationLenses.kt b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/validationLenses.kt new file mode 100644 index 0000000..47d37e9 --- /dev/null +++ b/src/commonMain/kotlin/com/lightningkite/reactive/lensing/validation/validationLenses.kt @@ -0,0 +1,73 @@ +package com.lightningkite.reactive.lensing.validation + +import com.lightningkite.reactive.core.MutableReactive +import com.lightningkite.reactive.core.MutableReactiveValue +import com.lightningkite.reactive.core.ReactiveState +import com.lightningkite.reactive.lensing.ModifyLens +import com.lightningkite.reactive.lensing.ModifyValueLens +import com.lightningkite.reactive.lensing.SetLens +import com.lightningkite.reactive.lensing.SetValueLens + +class ValidatedSetLens( + source: MutableValidated, + get: (T) -> L, + set: (L) -> T +) : MutableValidated, SetLens(source, get, set) { + override val node: IssueNode = IssueNode(parent = source.node) + override fun activate() { + super.activate() + node.connect() + } + override fun deactivate() { + super.deactivate() + node.disconnect() + } +} + +class ValidatedModifyLens( + source: MutableValidated, + get: (T) -> L, + modify: (T, L) -> T +) : MutableValidated, ModifyLens(source, get, modify) { + override val node: IssueNode = IssueNode(parent = source.node) + override fun activate() { + super.activate() + node.connect() + } + override fun deactivate() { + super.deactivate() + node.disconnect() + } +} + +class ValidatedSetValueLens( + source: MutableValidatedValue, + get: (T) -> L, + set: (L) -> T +) : MutableValidatedValue, SetValueLens(source, get, set) { + override val node: IssueNode = IssueNode(parent = source.node) + override fun activate() { + super.activate() + node.connect() + } + override fun deactivate() { + super.deactivate() + node.disconnect() + } +} + +class ValidatedModifyValueLens( + source: MutableValidatedValue, + get: (T) -> L, + modify: (T, L) -> T +) : MutableValidatedValue, ModifyValueLens(source, get, modify) { + override val node: IssueNode = IssueNode(parent = source.node) + override fun activate() { + super.activate() + node.connect() + } + override fun deactivate() { + super.deactivate() + node.disconnect() + } +} diff --git a/src/commonTest/kotlin/com/lightningkite/reactive/ReactivityTests.kt b/src/commonTest/kotlin/com/lightningkite/reactive/ReactivityTests.kt index 938b1f9..edb2043 100644 --- a/src/commonTest/kotlin/com/lightningkite/reactive/ReactivityTests.kt +++ b/src/commonTest/kotlin/com/lightningkite/reactive/ReactivityTests.kt @@ -471,7 +471,14 @@ class TestContext : CoroutineScopeHelpers() { } val incompleteKeys = HashSet() - override val coroutineContext: CoroutineContext = job + Dispatchers.Unconfined + object : StatusListener { + override val coroutineContext: CoroutineContext = + job + + CoroutineExceptionHandler { ctx, it -> + error = it + job.cancel() + } + + Dispatchers.Unconfined + + object : StatusListener { override fun loading(reactive: Reactive<*>) { var loading = false var excEnder: (() -> Unit)? = null diff --git a/src/commonTest/kotlin/com/lightningkite/reactive/ValidationTests.kt b/src/commonTest/kotlin/com/lightningkite/reactive/ValidationTests.kt new file mode 100644 index 0000000..afcdc9e --- /dev/null +++ b/src/commonTest/kotlin/com/lightningkite/reactive/ValidationTests.kt @@ -0,0 +1,218 @@ +package com.lightningkite.reactive + +import com.lightningkite.reactive.context.await +import com.lightningkite.reactive.context.invoke +import com.lightningkite.reactive.context.reactive +import com.lightningkite.reactive.core.Signal +import com.lightningkite.reactive.extensions.modify +import com.lightningkite.reactive.lensing.lensByElementWithIdentity +import com.lightningkite.reactive.lensing.validation.Issue +import com.lightningkite.reactive.lensing.validation.IssueNode +import com.lightningkite.reactive.lensing.validation.assert +import com.lightningkite.reactive.lensing.validation.issues +import com.lightningkite.reactive.lensing.validation.validate +import com.lightningkite.reactive.lensing.validation.validated +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.random.Random +import kotlin.random.nextInt +import kotlin.test.Test +import kotlin.test.assertEquals + +class ValidationTests { + data class Data( + val id: Int = 0, + val number: Double = 0.0, + val name: String = "" + ) + + @Test fun issuesArePropagated() { + testContext { + val root = IssueNode(null) + + val issues = ArrayList() + reactive { + issues.clear() + issues.addAll(root.issues()) + } + + assertEquals(0, issues.size) + + val child = root.child() + + assertEquals(0, issues.size) + + child.report(Issue.Invalid("Test")) + + assertEquals(1, issues.size) + + child.report(null) + + assertEquals(0, issues.size) + + val grandchild = child.child() + + assertEquals(0, issues.size) + + grandchild.report(Issue.Invalid("Test")) + + assertEquals(1, issues.size) + + grandchild.report(null) + + assertEquals(0, issues.size) + } + } + + @Test fun issuesArePropagatedThroughLensing() { + testContext { + val root = Signal(Data()).validated() + val id = root + .lens( + get = { it.id }, + modify = { o, it -> o.copy(id = it) } + ) + .assert("Greater than 0") { it > 0 } + + // Make sure that issues only propagate if the lens is being used + launch { + assertEquals(0, root.issues().size, "Issues are propagating before the lens is used") + } + + val context = reactive { rerunOn(id) } // Add dependency + + launch { + assertEquals(1, root.issues().size) + } + + id.value = 1 + + launch { + assertEquals(0, root.issues().size) + } + + id.value = 0 + + launch { + assertEquals(1, root.issues().size) + } + + context.cancel() + + launch { + assertEquals(0, root.issues().size) + } + } + } + + @Test fun validationLensingWorksByElement() { + testContext { + val listOfNumbers = Signal(listOf()).validated() + + val validated = listOfNumbers.lensByElementWithIdentity( + identity = { it } + ) { e -> + e.assert("Must be greater than zero") { it > 0 } + } + + launch { + assertEquals(0, listOfNumbers.issues().size) + } + + val loading = reactive { + println("Starting") + validated().forEach { element -> + rerunOn(element) + } + println("Done") + } + + repeat(10) { len -> + val list = List(len) { Random.Default.nextInt() } + println("\nList: $list") + listOfNumbers.value = list + launch { + loading.await() + val issues = listOfNumbers.issues() + println("Issues (${issues.size}): $issues") + assertEquals(list.count { it <= 0 }, issues.size) + validated().forEach { + val current = it() + if (current <= 0) { + println("Changing $current to 1") + it.set(1) + } + } + assertEquals(0, listOfNumbers.issues().size) + } + } + } + } + + @Test fun echosDontClearIssues() { + val root = Signal(Data()).validated() + + val id = root + .lens( + get = { it.id.toDouble() }, + modify = { o, it -> if (it != null) o.copy(id = it.toInt()) else o } + ) + .validate { + println("Validating value $it") + if (it == null) null + else if (it - it.toInt() != 0.0) { + println("String: $it") + println("Diff: ${it - it.toInt()}") + "Cannot be a decimal" + } + else null + } + + testContext { + launch { + assertEquals(0, root.issues().size) + } + + reactive { rerunOn(id) } + + launch { + assertEquals(0, root.issues().size) + } + + id.value = 1.5 + + launch { + assertEquals(1, root.issues().size) + delay(100) + assertEquals(1, root.issues().size) + } + + println("Going to 2") + + id.value = 2.0 + + launch { + assertEquals(0, root.issues().size) + } + + id.value = 1.5 + + launch { + assertEquals(1, root.issues().size) + } + + root.value = root.value.copy(id = 2) + + launch { + assertEquals(0, root.issues().size) + } + + id.value = 1.5 + root.value = root.value.copy(id = 2) + + launch { + assertEquals(0, root.issues().size) + } + } + } +} \ No newline at end of file