Repository: jwstegemann/fritz2
Issue: Detect missing match in RootStore for IdProvider based Sub-Stores

What I have encountered while thinking about issue #645 is the following shortcome of our sub-store mechanics:
```kotlin
fun main() {
    val frameworkStore = storeOf(listOf(Framework("fritz2"), Framework("React"), Framework("Spring")))

    render {
        button {
            label { +"Changes" }
            clicks.map {
                frameworkStore.current.map { if (it.name == "React") Framework("Redux") else it }
            } handledBy frameworkStore.update
        }
        ul {
            frameworkStore.data.renderEach({ it }) { framework ->
                console.log("rendere $framework")
                val storedFramework = frameworkStore.sub(framework) { it }
                //                                                  ^^^^^^
                // This absolutely must "throw" (log) an error at runtime!

                val storedName = storedFramework.sub(Framework.name())
                li {
                    p { +framework.name }
                    p { storedName.data.renderText() }
                    input {
                        value(storedName.data)
                        changes.values() handledBy storedName.update
                    }
                }
            }
        }
    }
}
```
We should definitly think about throwing some error, if there is no matching in the `RootStore` defined by the `idProvider` of the sub-store: As the `idProvider` is not stable, chaging the value will lead to the situation, that the store cannot find any corresponding element. Imho this is always not the expected behaviour and therefore clearly shows an error. 

Currently fritz2 silently "swallows" the updated data and will not propagate it upwards.

There should be some unit tests to guide the new behaviour.

Changed files:
core/src/jsMain/kotlin/dev/fritz2/core/substores.kt, core/src/commonTest/kotlin/dev/fritz2/core/lens.kt, core/src/jsTest/kotlin/dev/fritz2/core/substores.kt, core/src/commonMain/kotlin/dev/fritz2/core/lens.kt, core/src/jsMain/kotlin/dev/fritz2/core/mount.kt, core/src/jsMain/kotlin/dev/fritz2/core/job.kt

diff --git a/core/src/commonMain/kotlin/dev/fritz2/core/lens.kt b/core/src/commonMain/kotlin/dev/fritz2/core/lens.kt
index de9106b8..8f2f0836 100644
--- a/core/src/commonMain/kotlin/dev/fritz2/core/lens.kt
+++ b/core/src/commonMain/kotlin/dev/fritz2/core/lens.kt
@@ -86,7 +86,12 @@ typealias IdProvider<T, I> = (T) -> I
 /**
  * Occurs when [Lens] points to non-existing element.
  */
-class LensException: Exception() // is needed to cancel the coroutine correctly
+class CollectionLensGetException : Exception() // is needed to cancel the coroutine correctly
+
+/**
+ * Occurs when [Lens] tries to update a non-existing element.
+ */
+class CollectionLensSetException(message: String) : Exception(message)
 
 /**
  * creates a [Lens] pointing to a certain element in a [List]
@@ -99,10 +104,18 @@ fun <T, I> lensOf(element: T, idProvider: IdProvider<T, I>): Lens<List<T>, T> =
 
     override fun get(parent: List<T>): T = parent.find {
         idProvider(it) == idProvider(element)
-    } ?: throw LensException()
-
-    override fun set(parent: List<T>, value: T): List<T> = parent.map {
-        if (idProvider(it) == idProvider(value)) value else it
+    } ?: throw CollectionLensGetException()
+
+    override fun set(parent: List<T>, value: T): List<T> = ArrayList<T>(parent.size).apply {
+        var count = 0
+        parent.forEach { item ->
+            if (idProvider(item) == idProvider(element)) {
+                count++
+                add(value)
+            } else add(item)
+        }
+        if (count == 0) throw CollectionLensSetException("no item found with id='${idProvider(element)}'")
+        else if (count > 1) throw CollectionLensSetException("$count ambiguous items found with id='${idProvider(element)}'")
     }
 }
 
@@ -115,10 +128,12 @@ fun <T> lensOf(index: Int): Lens<List<T>, T> = object : Lens<List<T>, T> {
     override val id: String = index.toString()
 
     override fun get(parent: List<T>): T =
-        parent.getOrNull(index) ?: throw LensException()
+        parent.getOrNull(index) ?: throw CollectionLensGetException()
 
     override fun set(parent: List<T>, value: T): List<T> =
-        parent.mapIndexed { i, it -> if(i == index) value else it }
+        if (index < 0 || index >= parent.size) throw CollectionLensSetException("no item found with index='$index'")
+        else parent.mapIndexed { i, it -> if (i == index) value else it }
+
 }
 
 /**
@@ -130,9 +145,10 @@ fun <K, V> lensOf(key: K): Lens<Map<K, V>, V> = object : Lens<Map<K, V>, V> {
     override val id: String = key.toString()
 
     override fun get(parent: Map<K, V>): V =
-        parent[key] ?: throw LensException()
+        parent[key] ?: throw CollectionLensGetException()
 
-    override fun set(parent: Map<K, V>, value: V): Map<K, V> = parent.mapValues {
-        if(it.key == key) value else it.value
-    }
-}
+    override fun set(parent: Map<K, V>, value: V): Map<K, V> =
+        if (parent.containsKey(key)) parent + (key to value)
+        else throw CollectionLensSetException("no item found with key='$key'")
+
+}
\\ No newline at end of file
diff --git a/core/src/commonTest/kotlin/dev/fritz2/core/lens.kt b/core/src/commonTest/kotlin/dev/fritz2/core/lens.kt
index d29806b7..468ba18b 100644
--- a/core/src/commonTest/kotlin/dev/fritz2/core/lens.kt
+++ b/core/src/commonTest/kotlin/dev/fritz2/core/lens.kt
@@ -2,21 +2,21 @@ package dev.fritz2.core
 
 import kotlin.test.Test
 import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
 
 class LensesTests {
 
+    data class Tree(val name: String, val age: Int, val size: Size, val tags: List<String>)
     data class Size(val height: Double)
 
     private val heightLens = lens(Size::height.name, Size::height) { p, v -> p.copy(height = v) }
-
-    data class Tree(val name: String, val age: Int, val size: Size)
-
     private val ageLens = lens(Tree::age.name, Tree::age) { p, v -> p.copy(age = v) }
     private val sizeLens = lens(Tree::size.name, Tree::size) { p, v -> p.copy(size = v) }
+    private val tagLens = lens(Tree::tags.name, Tree::tags) { p, v -> p.copy(tags = v) }
 
     @Test
-    fun testFormat() {
-        val p = Tree("Mammut Tree", 3000, Size(84.3))
+    fun testFormatLens() {
+        val p = Tree("Mammut Tree", 3000, Size(84.3), emptyList())
 
         val intFormatLens = format(String::toInt, Int::toString)
         val doubleFormatLens = format(String::toDouble, Double::toString)
@@ -36,4 +36,29 @@ class LensesTests {
         assertEquals(formattedSizeLens.set(p, "55.12").size.height, 55.12, "set on formattedSizeLens did not work")
     }
 
+    @Test
+    fun testCollectionLensSetExceptions() {
+        val list = listOf("a", "b", "c", "a")
+        val map = mapOf(1 to "a", 2 to "b", 3 to "c")
+        assertEquals(listOf("a", "d", "c", "a"), lensOf("b") { it }.set(list, "d"))
+        assertEquals(listOf("a", "d", "c", "a"), lensOf<String>(1).set(list, "d"))
+        assertEquals(mapOf(1 to "a", 2 to "d", 3 to "c"), lensOf<Int, String>(2).set(map, "d"))
+
+        assertFailsWith<CollectionLensSetException> {
+            lensOf("d") { it }.set(list, "e")
+        }
+        assertFailsWith<CollectionLensSetException> {
+            lensOf("a") { it }.set(list, "d")
+        }
+        assertFailsWith<CollectionLensSetException> {
+            lensOf<String>(-1).set(list, "d")
+        }
+        assertFailsWith<CollectionLensSetException> {
+            lensOf<String>(list.size).set(list, "d")
+        }
+        assertFailsWith<CollectionLensSetException> {
+            lensOf<Int, String>(0).set(map, "d")
+        }
+    }
+
 }
\\ No newline at end of file
diff --git a/core/src/jsMain/kotlin/dev/fritz2/core/job.kt b/core/src/jsMain/kotlin/dev/fritz2/core/job.kt
index e93ee8e4..9da7009f 100644
--- a/core/src/jsMain/kotlin/dev/fritz2/core/job.kt
+++ b/core/src/jsMain/kotlin/dev/fritz2/core/job.kt
@@ -10,12 +10,12 @@ import kotlinx.coroutines.plus
 import org.w3c.dom.events.Event
 
 /**
- * Prints [Exception] to error-[console] by ignoring [LensException].
+ * Prints [Exception] to error-[console] by ignoring [CollectionLensGetException].
  */
 internal fun printErrorIgnoreLensException(cause: Throwable) {
     when(cause) {
-        is LensException -> {}
-        else -> console.error("ERROR: ${cause.message}", cause)
+        is CollectionLensGetException -> {}
+        else -> console.error(cause)
     }
 }
 
diff --git a/core/src/jsMain/kotlin/dev/fritz2/core/mount.kt b/core/src/jsMain/kotlin/dev/fritz2/core/mount.kt
index f9a7f7fe..40a6d6cc 100644
--- a/core/src/jsMain/kotlin/dev/fritz2/core/mount.kt
+++ b/core/src/jsMain/kotlin/dev/fritz2/core/mount.kt
@@ -168,7 +168,7 @@ inline fun <T> mountSimple(parentJob: Job, upstream: Flow<T>, crossinline collec
     (MainScope() + parentJob).launch(start = CoroutineStart.UNDISPATCHED) {
         upstream.onEach { collect(it) }.catch {
             when (it) {
-                is LensException -> {}
+                is CollectionLensGetException -> {}
                 else -> console.error(it)
             }
             // do not do anything here but canceling the coroutine, because this is an expected
diff --git a/core/src/jsMain/kotlin/dev/fritz2/core/substores.kt b/core/src/jsMain/kotlin/dev/fritz2/core/substores.kt
index 88a2fa11..e57eaeb6 100644
--- a/core/src/jsMain/kotlin/dev/fritz2/core/substores.kt
+++ b/core/src/jsMain/kotlin/dev/fritz2/core/substores.kt
@@ -55,6 +55,10 @@ class SubStore<P, D>(
         lens.get(it)
     }.distinctUntilChanged()
 
+    override fun errorHandler(cause: Throwable) {
+        parent.errorHandler(cause)
+    }
+
 }
 
 
@@ -70,7 +74,7 @@ fun <D, I> Store<List<D>>.sub(element: D, id: IdProvider<D, I>): SubStore<List<D
 }
 
 /**
- * creates a [SubStore] using a [RootStore] as parent using the index in the list
+ * creates a [SubStore] using a [RootStore] as parent using the [index] in the list
  * (do not use this, if you want to manipulate the list itself (add or move elements, filter, etc.).
  *
  * @param index position in the list to point to
@@ -79,3 +83,14 @@ fun <D> Store<List<D>>.sub(index: Int): SubStore<List<D>, D> {
     val lens = lensOf<D>(index)
     return SubStore(this, lens)
 }
+
+/**
+ * creates a [SubStore] using a [RootStore] as parent using the [key] in the map
+ * (do not use this, if you want to manipulate the map itself (add or move elements, filter, etc.).
+ *
+ * @param key in the map to point to
+ */
+fun <K, V> Store<Map<K, V>>.sub(key: K): SubStore<Map<K, V>, V> {
+    val lens = lensOf<K, V>(key)
+    return SubStore(this, lens)
+}
\\ No newline at end of file
diff --git a/core/src/jsTest/kotlin/dev/fritz2/core/substores.kt b/core/src/jsTest/kotlin/dev/fritz2/core/substores.kt
index 333cf30a..4877bf45 100644
--- a/core/src/jsTest/kotlin/dev/fritz2/core/substores.kt
+++ b/core/src/jsTest/kotlin/dev/fritz2/core/substores.kt
@@ -3,9 +3,11 @@ package dev.fritz2.core
 import dev.fritz2.runTest
 import kotlinx.browser.document
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.map
 import org.w3c.dom.HTMLDivElement
 import kotlin.test.Test
 import kotlin.test.assertEquals
+import kotlin.test.assertTrue
 
 class SubStoreTests {
 
@@ -116,4 +118,134 @@ class SubStoreTests {
 
         assertEquals(personFormatLens.get(newPerson), completeDiv.innerText, "parsing is not working")
     }
+
+    @Test
+    fun testSubStoreWithRenderEach() = runTest {
+
+        val id = Id.next()
+        val store = storeOf(listOf("a", "b", "c"))
+
+        lateinit var bStore: Store<String>
+
+        render {
+            div(id = id) {
+                store.renderEach({ it }, this) {
+                    if(it.current == "b") bStore = it
+                    p {
+                        it.data.renderText()
+                    }
+                }
+            }
+        }
+
+        delay(200)
+
+        val container = document.getElementById(id) as HTMLDivElement
+
+        assertEquals(3, container.childElementCount)
+        assertEquals("abc", container.textContent)
+
+        bStore.update("d")
+        delay(200)
+        assertEquals(3, container.childElementCount)
+        assertEquals("adc", container.textContent)
+    }
+
+    @Test
+    fun testSubStoreWithRenderEachSameId() = runTest {
+
+        val id = Id.next()
+        val store = object : RootStore<List<String>>(listOf("a", "b", "c", "b")) {
+            var throwable: Throwable? = null
+            override fun errorHandler(cause: Throwable) {
+                throwable = cause
+            }
+        }
+
+        var bStore: Store<String>? = null
+
+        render {
+            div(id = id) {
+                store.renderEach({ it }, this) {
+                    if(bStore == null && it.current == "b") bStore = it
+                    p {
+                        it.data.renderText()
+                    }
+                }
+            }
+        }
+
+        delay(200)
+
+        val container = document.getElementById(id) as HTMLDivElement
+
+        assertEquals(4, container.childElementCount)
+        assertEquals("abcb", container.textContent)
+        assertEquals(null, store.throwable)
+
+        bStore?.update("d")
+
+        delay(200)
+        assertTrue(store.throwable is CollectionLensSetException)
+    }
+
+    @Test
+    fun testSubStoreWithIndex() = runTest {
+
+        val id = Id.next()
+        val store = storeOf(listOf("a", "b", "c"))
+
+        render {
+            div(id = id) {
+                store.data.renderEach(into = this) {
+                    p {
+                        +it
+                    }
+                }
+            }
+        }
+
+        delay(200)
+
+        val container = document.getElementById(id) as HTMLDivElement
+
+        assertEquals(3, container.childElementCount)
+        assertEquals("abc", container.textContent)
+
+        store.sub(1).update("d")
+
+        delay(200)
+        assertEquals(3, container.childElementCount)
+        assertEquals("adc", container.textContent)
+    }
+
+    @Test
+    fun testSubStoreWithMap() = runTest {
+
+        val id = Id.next()
+        val store = storeOf(mapOf(1 to "a", 2 to "b", 3 to "c"))
+
+        render {
+            div(id = id) {
+                store.data.map { it.values.toList() }.renderEach(into = this) {
+                    p {
+                        +it
+                    }
+                }
+            }
+        }
+
+        delay(200)
+
+        val container = document.getElementById(id) as HTMLDivElement
+
+        assertEquals(3, container.childElementCount)
+        assertEquals("abc", container.textContent)
+
+        store.sub(2).update("d")
+
+        delay(200)
+        assertEquals(3, container.childElementCount)
+        assertEquals("adc", container.textContent)
+    }
 }
\\ No newline at end of file