Renamed FilteredListCell to SimpleFilteredListCell. Added a more complex version of FilteredListCell with bugs which highlighted a fundamental problem with the observable system. Various other improvements to the observable system.

This commit is contained in:
Daan Vanden Bosch 2022-04-23 22:14:10 +02:00
parent 276ffcb80b
commit 0cea2d816d
53 changed files with 1531 additions and 837 deletions

View File

@ -0,0 +1,7 @@
package world.phantasmal.core
inline fun assert(value: () -> Boolean) {
assert(value) { "An assertion failed." }
}
expect inline fun assert(value: () -> Boolean, lazyMessage: () -> Any)

View File

@ -0,0 +1,5 @@
package world.phantasmal.core
actual inline fun assert(value: () -> Boolean, lazyMessage: () -> Any) {
// TODO: Figure out a sensible way to do dev assertions in JS.
}

View File

@ -0,0 +1,11 @@
@file:JvmName("AssertionsJvm")
package world.phantasmal.core
val ASSERTIONS_ENABLED: Boolean = {}.javaClass.desiredAssertionStatus()
actual inline fun assert(value: () -> Boolean, lazyMessage: () -> Any) {
if (ASSERTIONS_ENABLED && !value()) {
throw AssertionError(lazyMessage())
}
}

View File

@ -3,9 +3,7 @@ package world.phantasmal.observable
typealias ChangeObserver<T> = (ChangeEvent<T>) -> Unit
open class ChangeEvent<out T>(
/**
* The observable's new value.
*/
/** The observable's new value. */
val value: T,
) {
operator fun component1() = value

View File

@ -8,7 +8,10 @@ interface Dependent {
* know that it will actually change, just that it might change. Always call [dependencyChanged]
* after calling this method.
*
* E.g. C depends on B and B depends on A. A is about to change, so it calls [dependencyMightChange] on B. At this point B doesn't know whether it will actually change since the new value of A doesn't necessarily result in a new value for B (e.g. B = A % 2 and A changes from 0 to 2). So B then calls [dependencyMightChange] on C.
* E.g. C depends on B and B depends on A. A is about to change, so it calls
* [dependencyMightChange] on B. At this point B doesn't know whether it will actually change
* since the new value of A doesn't necessarily result in a new value for B (e.g. B = A % 2 and
* A changes from 0 to 2). So B then calls [dependencyMightChange] on C.
*/
fun dependencyMightChange()

View File

@ -22,7 +22,7 @@ abstract class AbstractCell<T> : AbstractDependency(), Cell<T> {
}
}
protected fun emitDependencyChanged(event: ChangeEvent<*>?) {
protected fun emitDependencyChangedEvent(event: ChangeEvent<*>?) {
if (mightChangeEmitted) {
mightChangeEmitted = false
@ -31,4 +31,6 @@ abstract class AbstractCell<T> : AbstractDependency(), Cell<T> {
}
}
}
override fun toString(): String = "${this::class.simpleName}[$value]"
}

View File

@ -18,13 +18,15 @@ abstract class AbstractDependentCell<T> : AbstractCell<T>(), Dependent {
dependenciesActuallyChanged = true
}
if (--changingDependencies == 0) {
changingDependencies--
if (changingDependencies == 0) {
if (dependenciesActuallyChanged) {
dependenciesActuallyChanged = false
dependenciesChanged()
dependenciesFinishedChanging()
} else {
emitDependencyChanged(null)
emitDependencyChangedEvent(null)
}
}
}
@ -35,5 +37,9 @@ abstract class AbstractDependentCell<T> : AbstractCell<T>(), Dependent {
// change set is being completed.
}
protected abstract fun dependenciesChanged()
/**
* Called after a wave of dependencyMightChange notifications followed by an equal amount of
* dependencyChanged notifications of which at least one signified an actual change.
*/
protected abstract fun dependenciesFinishedChanging()
}

View File

@ -193,18 +193,24 @@ infix fun <T : Comparable<T>> Cell<T>.lt(value: T): Cell<Boolean> =
infix fun <T : Comparable<T>> Cell<T>.lt(other: Cell<T>): Cell<Boolean> =
map(this, other) { a, b -> a < b }
fun and(vararg cells: Cell<Boolean>): Cell<Boolean> =
DependentCell(*cells) { cells.all { it.value } }
infix fun Cell<Boolean>.and(other: Cell<Boolean>): Cell<Boolean> =
map(this, other) { a, b -> a && b }
infix fun Cell<Boolean>.and(other: Boolean): Cell<Boolean> =
if (other) this else falseCell()
infix fun Boolean.and(other: Cell<Boolean>): Cell<Boolean> =
if (this) other else falseCell()
infix fun Cell<Boolean>.or(other: Cell<Boolean>): Cell<Boolean> =
map(this, other) { a, b -> a || b }
infix fun Cell<Boolean>.or(other: Boolean): Cell<Boolean> =
if (other) trueCell() else this
infix fun Boolean.or(other: Cell<Boolean>): Cell<Boolean> =
if (this) trueCell() else other
infix fun Cell<Boolean>.xor(other: Cell<Boolean>): Cell<Boolean> =
// Use != because of https://youtrack.jetbrains.com/issue/KT-31277.
map(this, other) { a, b -> a != b }

View File

@ -22,6 +22,6 @@ class DelegatingCell<T>(
}
override fun emitDependencyChanged() {
emitDependencyChanged(ChangeEvent(value))
emitDependencyChangedEvent(ChangeEvent(value))
}
}

View File

@ -6,16 +6,19 @@ import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
/**
* Cell of which the value depends on 0 or more other cells.
* Cell of which the value depends on 0 or more dependencies.
*/
class DependentCell<T>(
private vararg val dependencies: Dependency,
private val compute: () -> T
private val compute: () -> T,
) : AbstractDependentCell<T>() {
private var _value: T? = null
override val value: T
get() {
// Recompute value every time when we have no dependents. At this point we're not yet a
// dependent of our own dependencies, and thus we won't automatically recompute our
// value when they change.
if (dependents.isEmpty()) {
_value = compute()
}
@ -25,6 +28,9 @@ class DependentCell<T>(
override fun addDependent(dependent: Dependent) {
if (dependents.isEmpty()) {
// Start actually depending on or dependencies when we get our first dependent.
// Make sure value is up-to-date here, because from now on `compute` will only be called
// when our dependencies change.
_value = compute()
for (dependency in dependencies) {
@ -39,20 +45,16 @@ class DependentCell<T>(
super.removeDependent(dependent)
if (dependents.isEmpty()) {
// Stop actually depending on our dependencies when we no longer have any dependents.
for (dependency in dependencies) {
dependency.removeDependent(this)
}
}
}
override fun dependenciesChanged() {
override fun dependenciesFinishedChanging() {
val newValue = compute()
if (newValue != _value) {
_value = newValue
emitDependencyChanged(ChangeEvent(newValue))
} else {
emitDependencyChanged(null)
}
_value = newValue
emitDependencyChangedEvent(ChangeEvent(newValue))
}
}

View File

@ -11,7 +11,7 @@ import world.phantasmal.observable.Dependent
*/
class FlatteningDependentCell<T>(
private vararg val dependencies: Dependency,
private val compute: () -> Cell<T>
private val compute: () -> Cell<T>,
) : AbstractDependentCell<T>() {
private var computedCell: Cell<T>? = null
@ -66,7 +66,7 @@ class FlatteningDependentCell<T>(
super.dependencyChanged(dependency, event)
}
override fun dependenciesChanged() {
override fun dependenciesFinishedChanging() {
if (shouldRecompute) {
computedCell?.removeDependent(this)
@ -79,12 +79,7 @@ class FlatteningDependentCell<T>(
}
val newValue = unsafeAssertNotNull(computedCell).value
if (newValue != _value) {
_value = newValue
emitDependencyChanged(ChangeEvent(newValue))
} else {
emitDependencyChanged(null)
}
_value = newValue
emitDependencyChangedEvent(ChangeEvent(newValue))
}
}

View File

@ -2,10 +2,19 @@ package world.phantasmal.observable.cell
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.nopDisposable
import world.phantasmal.observable.AbstractDependency
import world.phantasmal.observable.ChangeObserver
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
class ImmutableCell<T>(override val value: T) : Dependency, Cell<T> {
override fun addDependent(dependent: Dependent) {
// We don't remember our dependents because we never need to notify them of changes.
}
override fun removeDependent(dependent: Dependent) {
// Nothing to remove because we don't remember our dependents.
}
class ImmutableCell<T>(override val value: T) : AbstractDependency(), Cell<T> {
override fun observeChange(observer: ChangeObserver<T>): Disposable = nopDisposable()
override fun emitDependencyChanged() {

View File

@ -16,6 +16,6 @@ class SimpleCell<T>(value: T) : AbstractCell<T>(), MutableCell<T> {
}
override fun emitDependencyChanged() {
emitDependencyChanged(ChangeEvent(value))
emitDependencyChangedEvent(ChangeEvent(value))
}
}

View File

@ -61,15 +61,15 @@ abstract class AbstractDependentListCell<E> :
override fun observeListChange(observer: ListChangeObserver<E>): Disposable =
CallbackChangeObserver(this, observer)
final override fun dependenciesChanged() {
final override fun dependenciesFinishedChanging() {
val oldElements = value
computeElements()
emitDependencyChanged(
emitDependencyChangedEvent(
ListChangeEvent(
elements,
listOf(ListChange.Structural(
listOf(ListChange(
index = 0,
prevSize = oldElements.size,
removed = oldElements,

View File

@ -0,0 +1,218 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.core.unsafe.unsafeCast
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
abstract class AbstractFilteredListCell<E>(
protected val list: ListCell<E>,
) : AbstractListCell<E>(), Dependent {
/** Keeps track of number of changing dependencies during a change wave. */
private var changingDependencies = 0
/** Set during a change wave when [list] changes. */
private var listChangeEvent: ListChangeEvent<E>? = null
/** Set during a change wave when [predicateDependency] changes. */
private var predicateChanged = false
override val elements = mutableListOf<E>()
protected abstract val predicateDependency: Dependency
override val value: List<E>
get() {
if (dependents.isEmpty()) {
recompute()
}
return elementsWrapper
}
override fun addDependent(dependent: Dependent) {
val wasEmpty = dependents.isEmpty()
super.addDependent(dependent)
if (wasEmpty) {
list.addDependent(this)
predicateDependency.addDependent(this)
recompute()
}
}
override fun removeDependent(dependent: Dependent) {
super.removeDependent(dependent)
if (dependents.isEmpty()) {
predicateDependency.removeDependent(this)
list.removeDependent(this)
}
}
override fun dependencyMightChange() {
changingDependencies++
emitMightChange()
}
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
if (dependency === list) {
listChangeEvent = unsafeCast(event)
} else if (dependency === predicateDependency) {
predicateChanged = event != null
} else if (event != null) {
otherDependencyChanged(dependency)
}
changingDependencies--
if (changingDependencies == 0) {
if (predicateChanged) {
// Simply assume the entire list changes and recompute.
val removed = elementsWrapper
ignoreOtherChanges()
recompute()
emitDependencyChangedEvent(
ListChangeEvent(
elementsWrapper,
listOf(ListChange(0, removed.size, removed, elementsWrapper)),
)
)
} else {
// TODO: Conditionally copyAndResetWrapper?
copyAndResetWrapper()
val filteredChanges = mutableListOf<ListChange<E>>()
val listChangeEvent = this.listChangeEvent
if (listChangeEvent != null) {
for (change in listChangeEvent.changes) {
val prevSize = elements.size
// Map the incoming change index to an index into our own elements list.
// TODO: Avoid this loop by storing the index where an element "would" be
// if it passed the predicate.
var eventIndex = prevSize
for (index in change.index..maxDepIndex()) {
val i = mapIndex(index)
if (i != -1) {
eventIndex = i
break
}
}
// Process removals.
val removed = mutableListOf<E>()
for (element in change.removed) {
val index = removeIndexMapping(change.index)
if (index != -1) {
elements.removeAt(eventIndex)
removed.add(element)
}
}
// Process insertions.
val inserted = mutableListOf<E>()
var insertionIndex = eventIndex
for ((i, element) in change.inserted.withIndex()) {
if (applyPredicate(element)) {
insertIndexMapping(change.index + i, insertionIndex, element)
elements.add(insertionIndex, element)
inserted.add(element)
insertionIndex++
} else {
insertIndexMapping(change.index + i, -1, element)
}
}
// Shift mapped indices by a certain amount. This amount can be positive,
// negative or zero.
val diff = inserted.size - removed.size
if (diff != 0) {
// Indices before the change index stay the same. Newly inserted indices
// are already correct. So we only need to shift everything after the
// new indices.
for (index in (change.index + change.inserted.size)..maxDepIndex()) {
shiftIndexMapping(index, diff)
}
}
// Add a list change if something actually changed.
if (removed.isNotEmpty() || inserted.isNotEmpty()) {
filteredChanges.add(
ListChange(
eventIndex,
prevSize,
removed,
inserted,
)
)
}
}
}
processOtherChanges(filteredChanges)
if (filteredChanges.isEmpty()) {
emitDependencyChangedEvent(null)
} else {
emitDependencyChangedEvent(
ListChangeEvent(elementsWrapper, filteredChanges)
)
}
}
// Reset for next change wave.
listChangeEvent = null
predicateChanged = false
resetChangeWaveData()
}
}
/** Called when a dependency that's neither [list] nor [predicateDependency] has changed. */
protected abstract fun otherDependencyChanged(dependency: Dependency)
protected abstract fun ignoreOtherChanges()
protected abstract fun processOtherChanges(filteredChanges: MutableList<ListChange<E>>)
override fun emitDependencyChanged() {
// Nothing to do because AbstractFilteredListCell emits dependencyChanged immediately. We
// don't defer this operation because AbstractFilteredListCell only changes when there is no
// change set or the current change set is being completed.
}
protected abstract fun applyPredicate(element: E): Boolean
protected abstract fun maxDepIndex(): Int
/**
* Maps and index into [list] to an index into this list. Returns -1 if the given index does not
* point to an element that passes the predicate, i.e. the element is not in this list.
*/
protected abstract fun mapIndex(index: Int): Int
/**
* Removes the element at the given index into [list] from our mapping. Returns the previous
* index into our list.
*/
protected abstract fun removeIndexMapping(index: Int): Int
protected abstract fun insertIndexMapping(depIndex: Int, localIndex: Int, element: E)
/** Adds [shift] to the local index at [depIndex] if it's not -1. */
protected abstract fun shiftIndexMapping(depIndex: Int, shift: Int)
protected abstract fun recompute()
protected abstract fun resetChangeWaveData()
}

View File

@ -27,8 +27,7 @@ class DelegatingList<E>(var backingList: List<E>) : List<E> {
override fun subList(fromIndex: Int, toIndex: Int): List<E> =
backingList.subList(fromIndex, toIndex)
@Suppress("SuspiciousEqualsCombination")
override fun equals(other: Any?): Boolean = this === other || other == backingList
override fun equals(other: Any?): Boolean = other == backingList
override fun hashCode(): Int = backingList.hashCode()

View File

@ -1,217 +1,212 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.core.assert
import world.phantasmal.core.unsafe.unsafeCast
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
import world.phantasmal.observable.cell.Cell
class FilteredListCell<E>(
private val dependency: ListCell<E>,
private val predicate: (E) -> Boolean,
) : AbstractListCell<E>(), Dependent {
list: ListCell<E>,
private val predicate: Cell<(E) -> Cell<Boolean>>,
) : AbstractFilteredListCell<E>(list) {
/**
* Maps the dependency's indices to this list's indices. When an element of the dependency list
* Maps the dependency's indices to the corresponding index into this list and the result of the
* predicate applied to the element at that index. When an element of the dependency list
* doesn't pass the predicate, its index in this mapping is set to -1.
*/
private val indexMap = mutableListOf<Int>()
private val indexMap = mutableListOf<Mapping>()
override val elements = mutableListOf<E>()
private val changedPredicateResults = mutableListOf<Mapping>()
override val value: List<E>
get() {
if (dependents.isEmpty()) {
recompute()
}
return elementsWrapper
}
override fun addDependent(dependent: Dependent) {
if (dependents.isEmpty()) {
dependency.addDependent(this)
recompute()
}
super.addDependent(dependent)
}
override val predicateDependency: Dependency
get() = predicate
override fun removeDependent(dependent: Dependent) {
super.removeDependent(dependent)
if (dependents.isEmpty()) {
dependency.removeDependent(this)
for (mapping in indexMap) {
mapping.removeDependent(this)
}
}
}
override fun dependencyMightChange() {
emitMightChange()
override fun otherDependencyChanged(dependency: Dependency) {
assert { dependency is FilteredListCell<*>.Mapping }
changedPredicateResults.add(unsafeCast(dependency))
}
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
if (event is ListChangeEvent<*>) {
val prevSize = elements.size
val filteredChanges = mutableListOf<ListChange<E>>()
override fun ignoreOtherChanges() {
changedPredicateResults.clear()
}
for (change in event.changes) {
when (change) {
is ListChange.Structural -> {
// Figure out which elements should be removed from this list, then simply
// recompute the entire filtered list and finally figure out which elements
// have been added. Emit a change event if something actually changed.
@Suppress("UNCHECKED_CAST")
change as ListChange.Structural<E>
override fun processOtherChanges(filteredChanges: MutableList<ListChange<E>>) {
var shift = 0
val removed = mutableListOf<E>()
var eventIndex = -1
for ((dependencyIndex, mapping) in indexMap.withIndex()) {
if (changedPredicateResults.isEmpty()) {
break
}
change.removed.forEachIndexed { i, element ->
val index = indexMap[change.index + i]
if (changedPredicateResults.remove(mapping)) {
val result = mapping.predicateResult.value
val oldResult = mapping.index != -1
if (index != -1) {
removed.add(element)
if (result != oldResult) {
val prevSize = elements.size
if (eventIndex == -1) {
eventIndex = index
}
if (result) {
// TODO: Avoid this loop by storing the index where an element "would" be
// if it passed the predicate.
var insertionIndex = elements.size
for (index in (dependencyIndex + 1)..indexMap.lastIndex) {
val i = indexMap[index].index
if (i != -1) {
insertionIndex = i + shift
break
}
}
recompute()
val element = list.value[dependencyIndex]
elements.add(insertionIndex, element)
mapping.index = insertionIndex
shift++
val inserted = mutableListOf<E>()
filteredChanges.add(ListChange(
insertionIndex,
prevSize,
removed = emptyList(),
inserted = listOf(element),
))
} else {
val index = mapping.index + shift
val element = elements.removeAt(index)
mapping.index = -1
shift--
change.inserted.forEachIndexed { i, element ->
val index = indexMap[change.index + i]
if (index != -1) {
inserted.add(element)
if (eventIndex == -1) {
eventIndex = index
}
}
}
if (removed.isNotEmpty() || inserted.isNotEmpty()) {
check(eventIndex != -1)
filteredChanges.add(
ListChange.Structural(
eventIndex,
prevSize,
removed,
inserted
)
)
}
}
is ListChange.Element -> {
// Emit a structural or element change based on whether the updated element
// passes the predicate test and whether it was already in the elements list
// (i.e. whether it passed the predicate test before the update).
@Suppress("UNCHECKED_CAST")
change as ListChange.Element<E>
val index = indexMap[change.index]
if (predicate(change.updated)) {
if (index == -1) {
// If the element now passed the test and previously didn't pass,
// insert it and emit a Change event.
var insertIndex = elements.size
for (depIdx in (change.index + 1)..indexMap.lastIndex) {
val thisIdx = indexMap[depIdx]
if (thisIdx != -1) {
insertIndex = thisIdx
break
}
}
copyAndResetWrapper()
elements.add(insertIndex, change.updated)
indexMap[change.index] = insertIndex
for (depIdx in (change.index + 1)..indexMap.lastIndex) {
val thisIdx = indexMap[depIdx]
if (thisIdx != -1) {
indexMap[depIdx]++
}
}
filteredChanges.add(
ListChange.Structural(
insertIndex,
prevSize,
removed = emptyList(),
inserted = listOf(change.updated),
)
)
} else {
// Otherwise just propagate the element change.
filteredChanges.add(ListChange.Element(index, change.updated))
}
} else {
if (index != -1) {
// If the element now doesn't pass the test and it previously did
// pass, remove it and emit a structural change.
copyAndResetWrapper()
elements.removeAt(index)
indexMap[change.index] = -1
for (depIdx in (change.index + 1)..indexMap.lastIndex) {
val thisIdx = indexMap[depIdx]
if (thisIdx != -1) {
indexMap[depIdx]--
}
}
filteredChanges.add(
ListChange.Structural(
index,
prevSize,
removed = listOf(change.updated),
inserted = emptyList(),
)
)
} else {
// Otherwise just propagate the element change.
filteredChanges.add(ListChange.Element(index, change.updated))
}
}
filteredChanges.add(ListChange(
index,
prevSize,
removed = listOf(element),
inserted = emptyList(),
))
}
} else if (oldResult) {
mapping.index += shift
}
}
if (filteredChanges.isEmpty()) {
emitDependencyChanged(null)
} else {
emitDependencyChanged(ListChangeEvent(elementsWrapper, filteredChanges))
mapping.index += shift
}
} else {
emitDependencyChanged(null)
}
// Can still contain changed mappings at this point if e.g. an element was removed after its
// predicate result changed.
changedPredicateResults.clear()
}
override fun applyPredicate(element: E): Boolean =
predicate.value(element).value
override fun maxDepIndex(): Int =
indexMap.lastIndex
override fun mapIndex(index: Int): Int =
indexMap[index].index
override fun removeIndexMapping(index: Int): Int {
val mapping = indexMap.removeAt(index)
mapping.removeDependent(this)
return mapping.index
}
override fun insertIndexMapping(depIndex: Int, localIndex: Int, element: E) {
val mapping = Mapping(predicate.value(element), localIndex)
mapping.addDependent(this)
indexMap.add(depIndex, mapping)
}
override fun shiftIndexMapping(depIndex: Int, shift: Int) {
val mapping = indexMap[depIndex]
if (mapping.index != -1) {
mapping.index += shift
}
}
override fun emitDependencyChanged() {
// Nothing to do because FilteredListCell emits dependencyChanged immediately. We don't
// defer this operation because FilteredListCell only changes when there is no transaction
// or the current transaction is being committed.
}
private fun recompute() {
override fun recompute() {
copyAndResetWrapper()
elements.clear()
for (mapping in indexMap) {
mapping.removeDependent(this)
}
indexMap.clear()
dependency.value.forEach { element ->
if (predicate(element)) {
elements.add(element)
indexMap.add(elements.lastIndex)
} else {
indexMap.add(-1)
// Cache value here to facilitate loop unswitching.
val hasDependents = dependents.isNotEmpty()
val pred = predicate.value
for (element in list.value) {
val predicateResult = pred(element)
val index =
if (predicateResult.value) {
elements.add(element)
elements.lastIndex
} else {
-1
}
if (hasDependents) {
val mapping = Mapping(predicateResult, index)
mapping.addDependent(this)
indexMap.add(mapping)
}
}
}
override fun resetChangeWaveData() {
changedPredicateResults.clear()
}
private inner class Mapping(
val predicateResult: Cell<Boolean>,
/**
* The index into [elements] if the element passes the predicate. -1 If the element does not
* pass the predicate.
*/
var index: Int,
) : Dependent, Dependency {
override fun dependencyMightChange() {
this@FilteredListCell.dependencyMightChange()
}
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
assert { dependency === predicateResult }
this@FilteredListCell.dependencyChanged(this, event)
}
override fun addDependent(dependent: Dependent) {
assert { dependent === this@FilteredListCell }
predicateResult.addDependent(this)
}
override fun removeDependent(dependent: Dependent) {
assert { dependent === this@FilteredListCell }
predicateResult.removeDependent(this)
}
override fun emitDependencyChanged() {
// Nothing to do.
}
}
}

View File

@ -2,20 +2,29 @@ package world.phantasmal.observable.cell.list
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.nopDisposable
import world.phantasmal.observable.AbstractDependency
import world.phantasmal.observable.ChangeObserver
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.cell
import world.phantasmal.observable.cell.falseCell
import world.phantasmal.observable.cell.trueCell
class ImmutableListCell<E>(private val elements: List<E>) : AbstractDependency(), ListCell<E> {
class ImmutableListCell<E>(private val elements: List<E>) : Dependency, ListCell<E> {
override val size: Cell<Int> = cell(elements.size)
override val empty: Cell<Boolean> = if (elements.isEmpty()) trueCell() else falseCell()
override val notEmpty: Cell<Boolean> = if (elements.isNotEmpty()) trueCell() else falseCell()
override val value: List<E> = elements
override fun addDependent(dependent: Dependent) {
// We don't remember our dependents because we never need to notify them of changes.
}
override fun removeDependent(dependent: Dependent) {
// Nothing to remove because we don't remember our dependents.
}
override fun get(index: Int): E =
elements[index]

View File

@ -1,7 +1,9 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.Observable
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.DependentCell
import world.phantasmal.observable.cell.ImmutableCell
private val EMPTY_LIST_CELL = ImmutableListCell<Nothing>(emptyList())
@ -12,11 +14,20 @@ fun <E> listCell(vararg elements: E): ListCell<E> = ImmutableListCell(elements.t
fun <E> emptyListCell(): ListCell<E> = EMPTY_LIST_CELL
/** Returns a mutable list cell containing [elements]. */
fun <E> mutableListCell(
vararg elements: E,
extractDependencies: DependenciesExtractor<E>? = null,
): MutableListCell<E> =
SimpleListCell(mutableListOf(*elements), extractDependencies)
fun <E> mutableListCell(vararg elements: E): MutableListCell<E> =
SimpleListCell(mutableListOf(*elements))
/**
* Returns a cell that changes whenever this list cell is structurally changed or when its
* individual elements change.
*
* @param extractObservables Called on each element to determine which element changes should be
* observed.
*/
fun <E> ListCell<E>.dependingOnElements(
extractObservables: (element: E) -> Array<out Observable<*>>,
): Cell<List<E>> =
ListElementsDependentCell(this, extractObservables)
fun <E, R> ListCell<E>.listMap(transform: (E) -> R): ListCell<R> =
DependentListCell(this) { value.map(transform) }
@ -31,13 +42,16 @@ fun <E> ListCell<E>.sumOf(selector: (E) -> Int): Cell<Int> =
DependentCell(this) { value.sumOf(selector) }
fun <E> ListCell<E>.filtered(predicate: (E) -> Boolean): ListCell<E> =
FilteredListCell(this, predicate)
SimpleFilteredListCell(this, ImmutableCell(predicate))
fun <E> ListCell<E>.filtered(predicate: Cell<(E) -> Boolean>): ListCell<E> =
DependentListCell(this, predicate) { value.filter(predicate.value) }
SimpleFilteredListCell(this, predicate)
fun <E> ListCell<E>.sortedWith(comparator: Cell<Comparator<E>>): ListCell<E> =
DependentListCell(this, comparator) { value.sortedWith(comparator.value) }
fun <E> ListCell<E>.filteredCell(predicate: (E) -> Cell<Boolean>): ListCell<E> =
FilteredListCell(this, ImmutableCell(predicate))
fun <E> ListCell<E>.filteredCell(predicate: Cell<(E) -> Cell<Boolean>>): ListCell<E> =
FilteredListCell(this, predicate)
fun <E> ListCell<E>.firstOrNull(): Cell<E?> =
DependentCell(this) { value.firstOrNull() }

View File

@ -7,39 +7,19 @@ class ListChangeEvent<out E>(
val changes: List<ListChange<E>>,
) : ChangeEvent<List<E>>(value)
sealed class ListChange<out E> {
/**
* Represents a structural change to a list cell. E.g. an element is inserted or removed.
*/
class Structural<out E>(
val index: Int,
val prevSize: Int,
/**
* The elements that were removed from the list at [index].
*
* Do not keep long-lived references to a [ChangeEvent]'s [removed] list, it may or may not
* be mutated when the originating [ListCell] is mutated.
*/
val removed: List<E>,
/**
* The elements that were inserted into the list at [index].
*
* Do not keep long-lived references to a [ChangeEvent]'s [inserted] list, it may or may not
* be mutated when the originating [ListCell] is mutated.
*/
val inserted: List<E>,
) : ListChange<E>() {
val allRemoved: Boolean get() = removed.size == prevSize
}
/**
* Represents a change to an element in a list cell. Will only be emitted if the list is
* configured to do so.
*/
class Element<E>(
val index: Int,
val updated: E,
) : ListChange<E>()
/**
* Represents a structural change to a list cell. E.g. an element is inserted or removed.
*/
class ListChange<out E>(
val index: Int,
val prevSize: Int,
/** The elements that were removed from the list at [index]. */
val removed: List<E>,
/** The elements that were inserted into the list at [index]. */
val inserted: List<E>,
) {
/** True when this change resulted in the removal of all elements from the list. */
val allRemoved: Boolean get() = removed.size == prevSize
}
typealias ListChangeObserver<E> = (ListChangeEvent<E>) -> Unit

View File

@ -0,0 +1,140 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.core.splice
import world.phantasmal.core.unsafe.unsafeCast
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
import world.phantasmal.observable.Observable
import world.phantasmal.observable.cell.AbstractCell
/**
* Depends on a [ListCell] and zero or more [Observable]s per element in the list.
*/
class ListElementsDependentCell<E>(
private val list: ListCell<E>,
private val extractObservables: (element: E) -> Array<out Observable<*>>,
) : AbstractCell<List<E>>(), Dependent {
/** An array of dependencies per [list] element, extracted by [extractObservables]. */
private val elementDependencies = mutableListOf<Array<out Dependency>>()
/** Keeps track of how many of our dependencies are about to (maybe) change. */
private var changingDependencies = 0
/**
* Set to true once one of our dependencies has actually changed. Reset to false whenever
* [changingDependencies] hits 0 again.
*/
private var dependenciesActuallyChanged = false
private var listChangeEvent: ListChangeEvent<E>? = null
override val value: List<E>
get() = list.value
override fun addDependent(dependent: Dependent) {
if (dependents.isEmpty()) {
// Once we have our first dependent, we start depending on our own dependencies.
list.addDependent(this)
for (element in list.value) {
val dependencies = extractObservables(element)
for (dependency in dependencies) {
dependency.addDependent(this)
}
elementDependencies.add(dependencies)
}
}
super.addDependent(dependent)
}
override fun removeDependent(dependent: Dependent) {
super.removeDependent(dependent)
if (dependents.isEmpty()) {
// At this point we have no more dependents, so we can stop depending on our own
// dependencies.
for (dependencies in elementDependencies) {
for (dependency in dependencies) {
dependency.removeDependent(this)
}
}
elementDependencies.clear()
list.removeDependent(this)
}
}
override fun dependencyMightChange() {
changingDependencies++
emitMightChange()
}
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
if (event != null) {
dependenciesActuallyChanged = true
// Simply store all list changes when the changing dependency is our list dependency. We
// don't update our dependencies yet to avoid receiving dependencyChanged notifications
// from newly inserted dependencies for which we haven't received any
// dependencyMightChange notifications and to avoid *NOT* receiving dependencyChanged
// notifications from removed dependencies for which we *HAVE* received
// dependencyMightChange notifications.
if (dependency === list) {
listChangeEvent = unsafeCast(event)
}
}
changingDependencies--
if (changingDependencies == 0) {
// All of our dependencies have finished changing.
// At this point we can remove this dependent from the removed elements' dependencies
// and add it to the newly inserted elements' dependencies.
listChangeEvent?.let { listChangeEvent ->
for (change in listChangeEvent.changes) {
for (i in change.index until (change.index + change.removed.size)) {
for (elementDependency in elementDependencies[i]) {
elementDependency.removeDependent(this)
}
}
val inserted = change.inserted.map(extractObservables)
elementDependencies.splice(
startIndex = change.index,
amount = change.removed.size,
elements = inserted,
)
for (elementDependencies in inserted) {
for (elementDependency in elementDependencies) {
elementDependency.addDependent(this)
}
}
}
}
// Reset for the next change wave.
listChangeEvent = null
if (dependenciesActuallyChanged) {
dependenciesActuallyChanged = false
emitDependencyChangedEvent(ChangeEvent(list.value))
} else {
emitDependencyChangedEvent(null)
}
}
}
override fun emitDependencyChanged() {
// Nothing to do because ListElementsDependentCell emits dependencyChanged immediately. We
// don't defer this operation because ListElementsDependentCell only changes when there is
// no transaction or the current transaction is being committed.
}
}

View File

@ -0,0 +1,80 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.cell.Cell
class SimpleFilteredListCell<E>(
list: ListCell<E>,
private val predicate: Cell<(E) -> Boolean>,
) : AbstractFilteredListCell<E>(list) {
/**
* Maps the dependency's indices to this list's indices. When an element of the dependency list
* doesn't pass the predicate, its index in this mapping is set to -1.
*
* This is not a performance improvement but a requirement. We can't determine an element's
* index into our own [elements] list by using e.g. indexOf because a list can contain the same
* element multiple times.
*/
private val indexMap = mutableListOf<Int>()
override val predicateDependency: Dependency
get() = predicate
override fun otherDependencyChanged(dependency: Dependency) {
// Unreachable code path.
error("Unexpected dependency.")
}
override fun ignoreOtherChanges() {
// Nothing to ignore.
}
override fun processOtherChanges(filteredChanges: MutableList<ListChange<E>>) {
// Nothing to process.
}
override fun applyPredicate(element: E): Boolean =
predicate.value(element)
override fun maxDepIndex(): Int =
indexMap.lastIndex
override fun mapIndex(index: Int): Int =
indexMap[index]
override fun removeIndexMapping(index: Int): Int =
indexMap.removeAt(index)
override fun insertIndexMapping(depIndex: Int, localIndex: Int, element: E) {
indexMap.add(depIndex, localIndex)
}
override fun shiftIndexMapping(depIndex: Int, shift: Int) {
val i = indexMap[depIndex]
if (i != -1) {
indexMap[depIndex] = i + shift
}
}
override fun recompute() {
copyAndResetWrapper()
elements.clear()
indexMap.clear()
val pred = predicate.value
for (element in list.value) {
if (pred(element)) {
elements.add(element)
indexMap.add(elements.lastIndex)
} else {
indexMap.add(-1)
}
}
}
override fun resetChangeWaveData() {
// Nothing to reset.
}
}

View File

@ -1,32 +1,15 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.replaceAll
import world.phantasmal.core.unsafe.unsafeAssertNotNull
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.ChangeManager
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
typealias DependenciesExtractor<E> = (element: E) -> Array<Dependency>
/**
* @param elements The backing list for this [ListCell].
* @param extractDependencies Extractor function called on each element in this list, changes to
* the returned dependencies will be propagated via [ListChange.Element]s in a [ListChangeEvent]
* event.
*/
class SimpleListCell<E>(
override val elements: MutableList<E>,
private val extractDependencies: DependenciesExtractor<E>? = null,
) : AbstractListCell<E>(), MutableListCell<E> {
/**
* Dependents of dependencies related to this list's elements. Allows us to propagate changes to
* elements via [ListChangeEvent]s.
*/
private val elementDependents = mutableListOf<ElementDependent>()
private var changingElements = 0
private var changes = mutableListOf<ListChange<E>>()
override var value: List<E>
@ -45,18 +28,12 @@ class SimpleListCell<E>(
copyAndResetWrapper()
val removed = elements.set(index, element)
if (dependents.isNotEmpty() && extractDependencies != null) {
elementDependents[index].dispose()
elementDependents[index] = ElementDependent(index, element)
}
changes.add(ListChange.Structural(
finalizeChange(
index,
prevSize = elements.size,
removed = listOf(removed),
inserted = listOf(element),
))
ChangeManager.changed(this)
)
return removed
}
@ -68,7 +45,7 @@ class SimpleListCell<E>(
copyAndResetWrapper()
elements.add(element)
finalizeStructuralChange(
finalizeChange(
index,
prevSize = index,
removed = emptyList(),
@ -84,7 +61,7 @@ class SimpleListCell<E>(
copyAndResetWrapper()
elements.add(index, element)
finalizeStructuralChange(index, prevSize, removed = emptyList(), inserted = listOf(element))
finalizeChange(index, prevSize, removed = emptyList(), inserted = listOf(element))
}
override fun remove(element: E): Boolean {
@ -107,7 +84,7 @@ class SimpleListCell<E>(
copyAndResetWrapper()
val removed = elements.removeAt(index)
finalizeStructuralChange(index, prevSize, removed = listOf(removed), inserted = emptyList())
finalizeChange(index, prevSize, removed = listOf(removed), inserted = emptyList())
return removed
}
@ -120,7 +97,7 @@ class SimpleListCell<E>(
copyAndResetWrapper()
this.elements.replaceAll(elements)
finalizeStructuralChange(index = 0, prevSize, removed, inserted = elementsWrapper)
finalizeChange(index = 0, prevSize, removed, inserted = elementsWrapper)
}
override fun replaceAll(elements: Sequence<E>) {
@ -132,7 +109,7 @@ class SimpleListCell<E>(
copyAndResetWrapper()
this.elements.replaceAll(elements)
finalizeStructuralChange(index = 0, prevSize, removed, inserted = elementsWrapper)
finalizeChange(index = 0, prevSize, removed, inserted = elementsWrapper)
}
override fun splice(fromIndex: Int, removeCount: Int, newElement: E) {
@ -149,7 +126,7 @@ class SimpleListCell<E>(
repeat(removeCount) { elements.removeAt(fromIndex) }
elements.add(fromIndex, newElement)
finalizeStructuralChange(fromIndex, prevSize, removed, inserted = listOf(newElement))
finalizeChange(fromIndex, prevSize, removed, inserted = listOf(newElement))
}
override fun clear() {
@ -161,7 +138,7 @@ class SimpleListCell<E>(
copyAndResetWrapper()
elements.clear()
finalizeStructuralChange(index = 0, prevSize, removed, inserted = emptyList())
finalizeChange(index = 0, prevSize, removed, inserted = emptyList())
}
override fun sortWith(comparator: Comparator<E>) {
@ -177,7 +154,7 @@ class SimpleListCell<E>(
throwable = e
}
finalizeStructuralChange(
finalizeChange(
index = 0,
prevSize = elements.size,
removed,
@ -189,32 +166,10 @@ class SimpleListCell<E>(
}
}
override fun addDependent(dependent: Dependent) {
if (dependents.isEmpty() && extractDependencies != null) {
for ((index, element) in elements.withIndex()) {
elementDependents.add(ElementDependent(index, element))
}
}
super.addDependent(dependent)
}
override fun removeDependent(dependent: Dependent) {
super.removeDependent(dependent)
if (dependents.isEmpty()) {
for (elementDependent in elementDependents) {
elementDependent.dispose()
}
elementDependents.clear()
}
}
override fun emitDependencyChanged() {
val currentChanges = changes
changes = mutableListOf()
emitDependencyChanged(ListChangeEvent(elementsWrapper, currentChanges))
emitDependencyChangedEvent(ListChangeEvent(elementsWrapper, currentChanges))
}
private fun checkIndex(index: Int, maxIndex: Int) {
@ -225,75 +180,13 @@ class SimpleListCell<E>(
}
}
private fun finalizeStructuralChange(
private fun finalizeChange(
index: Int,
prevSize: Int,
removed: List<E>,
inserted: List<E>,
) {
if (dependents.isNotEmpty() && extractDependencies != null) {
repeat(removed.size) {
elementDependents.removeAt(index).dispose()
}
for ((i, element) in inserted.withIndex()) {
val elementIdx = index + i
elementDependents.add(elementIdx, ElementDependent(elementIdx, element))
}
val shift = inserted.size - removed.size
for (i in (index + inserted.size)..elementDependents.lastIndex) {
elementDependents[i].index += shift
}
}
changes.add(ListChange.Structural(index, prevSize, removed, inserted))
changes.add(ListChange(index, prevSize, removed, inserted))
ChangeManager.changed(this)
}
private inner class ElementDependent(
var index: Int,
private val element: E,
) : Dependent, Disposable {
private val dependencies = unsafeAssertNotNull(extractDependencies)(element)
private var changingDependencies = 0
private var dependenciesActuallyChanged = false
init {
for (dependency in dependencies) {
dependency.addDependent(this)
}
}
override fun dispose() {
for (dependency in dependencies) {
dependency.removeDependent(this)
}
}
override fun dependencyMightChange() {
if (changingDependencies++ == 0) {
changingElements++
emitMightChange()
}
}
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
if (event != null) {
dependenciesActuallyChanged = true
}
if (--changingDependencies == 0) {
if (dependenciesActuallyChanged) {
dependenciesActuallyChanged = false
changes.add(ListChange.Element(index, element))
}
if (--changingElements == 0) {
ChangeManager.changed(this@SimpleListCell)
}
}
}
}
}

View File

@ -42,6 +42,10 @@ interface DependencyTests : ObservableTestSuite {
interface Provider {
val dependency: Dependency
/**
* Makes [dependency] emit [Dependent.dependencyMightChange] followed by
* [Dependent.dependencyChanged] with a non-null event.
*/
fun emit()
}
}

View File

@ -51,12 +51,12 @@ interface CellTests : ObservableTests {
fun emits_correct_value_in_change_events() = test {
val p = createProvider()
var prevValue: Any?
var observedValue: Any? = null
var prevValue: Snapshot?
var observedValue: Snapshot? = null
disposer.add(p.observable.observeChange { changeEvent ->
assertNull(observedValue)
observedValue = changeEvent.value
observedValue = changeEvent.value.snapshot()
})
repeat(3) {
@ -69,7 +69,7 @@ interface CellTests : ObservableTests {
// it should be equal to the cell's current value.
assertNotNull(observedValue)
assertNotEquals(prevValue, observedValue)
assertEquals(p.observable.value, observedValue)
assertEquals(p.observable.value.snapshot(), observedValue)
}
}
@ -82,20 +82,20 @@ interface CellTests : ObservableTests {
fun reflects_changes_without_observers() = test {
val p = createProvider()
var old: Any?
var old: Snapshot?
repeat(5) {
// Value should change after emit.
old = p.observable.value
old = p.observable.value.snapshot()
p.emit()
val new = p.observable.value
val new = p.observable.value.snapshot()
assertNotEquals(old, new)
// Value should not change when emit hasn't been called since the last access.
assertEquals(new, p.observable.value)
assertEquals(new, p.observable.value.snapshot())
}
}
@ -120,10 +120,10 @@ interface CellTests : ObservableTests {
@Test
fun propagates_changes_to_mapped_cell() = test {
val p = createProvider()
val mapped = p.observable.map { it.hashCode() }
val mapped = p.observable.map { it.snapshot() }
val initialValue = mapped.value
var observedValue: Int? = null
var observedValue: Snapshot? = null
disposer.add(mapped.observeChange { changeEvent ->
assertNull(observedValue)
@ -140,10 +140,10 @@ interface CellTests : ObservableTests {
fun propagates_changes_to_flat_mapped_cell() = test {
val p = createProvider()
val mapped = p.observable.flatMap { ImmutableCell(it.hashCode()) }
val mapped = p.observable.flatMap { ImmutableCell(it.snapshot()) }
val initialValue = mapped.value
var observedValue: Int? = null
var observedValue: Snapshot? = null
disposer.add(mapped.observeChange {
assertNull(observedValue)
@ -160,3 +160,16 @@ interface CellTests : ObservableTests {
override val observable: Cell<Any>
}
}
/** See [snapshot]. */
private typealias Snapshot = String
/**
* We use toString to create "snapshots" of values throughout the tests. Most of the time cells will
* actually have a new value after emitting a change event, but this is not always the case with
* more complex cells or cells that point to complex values. So instead of keeping references to
* values and comparing them with == (or using e.g. assertEquals), we compare snapshots.
*
* This of course assumes that all values have sensible toString implementations.
*/
private fun Any?.snapshot(): Snapshot = toString()

View File

@ -1,21 +1,61 @@
package world.phantasmal.observable.cell
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.*
interface CellWithDependenciesTests : CellTests {
override fun createProvider(): Provider
fun createWithDependencies(
dependency1: Cell<Int>,
dependency2: Cell<Int>,
dependency3: Cell<Int>,
): Cell<Any>
@Test
fun emits_precisely_once_when_all_of_its_dependencies_emit() = test {
val root = SimpleCell(5)
val branch1 = DependentCell(root) { root.value * 2 }
val branch2 = DependentCell(root) { root.value * 3 }
val branch3 = DependentCell(root) { root.value * 4 }
val leaf = createWithDependencies(branch1, branch2, branch3)
var dependencyMightChangeCalled = false
var dependencyChangedCalled = false
leaf.addDependent(object : Dependent {
override fun dependencyMightChange() {
assertFalse(dependencyMightChangeCalled)
assertFalse(dependencyChangedCalled)
dependencyMightChangeCalled = true
}
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
assertTrue(dependencyMightChangeCalled)
assertFalse(dependencyChangedCalled)
assertEquals(leaf, dependency)
assertNotNull(event)
dependencyChangedCalled = true
}
})
repeat(5) { index ->
dependencyMightChangeCalled = false
dependencyChangedCalled = false
root.value += 1
assertTrue(dependencyMightChangeCalled, "repetition $index")
assertTrue(dependencyChangedCalled, "repetition $index")
}
}
@Test
fun is_recomputed_once_even_when_many_dependencies_change() = test {
val p = createProvider()
val root = SimpleCell(5)
val branch1 = root.map { it * 2 }
val branch2 = root.map { it * 4 }
val leaf = p.createWithDependencies(branch1, branch2)
val branch1 = DependentCell(root) { root.value * 2 }
val branch2 = DependentCell(root) { root.value * 3 }
val branch3 = DependentCell(root) { root.value * 4 }
val leaf = createWithDependencies(branch1, branch2, branch3)
var observedChanges = 0
@ -30,29 +70,31 @@ interface CellWithDependenciesTests : CellTests {
@Test
fun doesnt_register_as_dependent_of_its_dependencies_until_it_has_dependents_itself() = test {
val p = createProvider()
val dependency1 = TestCell()
val dependency2 = TestCell()
val dependency3 = TestCell()
val dependency = object : AbstractCell<Int>() {
val publicDependents: List<Dependent> = dependents
val cell = createWithDependencies(dependency1, dependency2, dependency3)
override val value: Int = 5
override fun emitDependencyChanged() {
// Not going to change.
throw NotImplementedError()
}
}
val cell = p.createWithDependencies(dependency)
assertTrue(dependency.publicDependents.isEmpty())
assertTrue(dependency1.publicDependents.isEmpty())
assertTrue(dependency2.publicDependents.isEmpty())
assertTrue(dependency3.publicDependents.isEmpty())
disposer.add(cell.observeChange { })
assertEquals(1, dependency.publicDependents.size)
assertEquals(1, dependency1.publicDependents.size)
assertEquals(1, dependency2.publicDependents.size)
assertEquals(1, dependency3.publicDependents.size)
}
interface Provider : CellTests.Provider {
fun createWithDependencies(vararg dependencies: Cell<Int>): Cell<Any>
private class TestCell : AbstractCell<Int>() {
val publicDependents: List<Dependent> = dependents
override val value: Int = 5
override fun emitDependencyChanged() {
// Not going to change.
throw NotImplementedError()
}
}
}

View File

@ -8,7 +8,16 @@ class DependentCellTests : RegularCellTests, CellWithDependenciesTests {
return DependentCell(dependency) { dependency.value }
}
class Provider : CellTests.Provider, CellWithDependenciesTests.Provider {
override fun createWithDependencies(
dependency1: Cell<Int>,
dependency2: Cell<Int>,
dependency3: Cell<Int>,
) =
DependentCell(dependency1, dependency2, dependency3) {
dependency1.value + dependency2.value + dependency3.value
}
class Provider : CellTests.Provider {
private val dependencyCell = SimpleCell(1)
override val observable = DependentCell(dependencyCell) { 2 * dependencyCell.value }
@ -16,8 +25,5 @@ class DependentCellTests : RegularCellTests, CellWithDependenciesTests {
override fun emit() {
dependencyCell.value += 2
}
override fun createWithDependencies(vararg dependencies: Cell<Int>) =
DependentCell(*dependencies) { dependencies.sumOf { it.value } }
}
}

View File

@ -14,7 +14,16 @@ class FlatteningDependentCellTransitiveDependencyEmitsTests :
return FlatteningDependentCell(dependency) { dependency.value }
}
class Provider : CellTests.Provider, CellWithDependenciesTests.Provider {
override fun createWithDependencies(
dependency1: Cell<Int>,
dependency2: Cell<Int>,
dependency3: Cell<Int>,
) =
FlatteningDependentCell(dependency1, dependency2, dependency3) {
ImmutableCell(dependency1.value + dependency2.value + dependency3.value)
}
class Provider : CellTests.Provider {
// The transitive dependency can change.
private val transitiveDependency = SimpleCell(5)
@ -28,8 +37,5 @@ class FlatteningDependentCellTransitiveDependencyEmitsTests :
// Update the transitive dependency.
transitiveDependency.value += 5
}
override fun createWithDependencies(vararg dependencies: Cell<Int>): Cell<Any> =
FlatteningDependentCell(*dependencies) { ImmutableCell(dependencies.sumOf { it.value }) }
}
}

View File

@ -0,0 +1,306 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.ChangeManager
import world.phantasmal.observable.cell.CellWithDependenciesTests
import world.phantasmal.observable.cell.ImmutableCell
import world.phantasmal.observable.cell.SimpleCell
import kotlin.test.*
interface AbstractFilteredListCellTests : ListCellTests, CellWithDependenciesTests {
@Test
fun contains_only_values_that_match_the_predicate() = test {
val dep = SimpleListCell(mutableListOf("a", "b"))
val list = SimpleFilteredListCell(dep, predicate = ImmutableCell { 'a' in it })
assertEquals(1, list.value.size)
assertEquals("a", list.value[0])
dep.add("foo")
dep.add("bar")
assertEquals(2, list.value.size)
assertEquals("a", list.value[0])
assertEquals("bar", list.value[1])
dep.add("quux")
dep.add("qaax")
assertEquals(3, list.value.size)
assertEquals("a", list.value[0])
assertEquals("bar", list.value[1])
assertEquals("qaax", list.value[2])
}
@Test
fun only_emits_when_necessary() = test {
val dep = SimpleListCell<Int>(mutableListOf())
val list = SimpleFilteredListCell(dep, predicate = ImmutableCell { it % 2 == 0 })
var changes = 0
var listChanges = 0
disposer.add(list.observeChange {
changes++
})
disposer.add(list.observeListChange {
listChanges++
})
dep.add(1)
dep.add(3)
dep.add(5)
assertEquals(0, changes)
assertEquals(0, listChanges)
dep.add(0)
dep.add(2)
dep.add(4)
assertEquals(3, changes)
assertEquals(3, listChanges)
}
@Test
fun emits_correct_change_events() = test {
val dep = SimpleListCell<Int>(mutableListOf())
val list = SimpleFilteredListCell(dep, predicate = ImmutableCell { it % 2 == 0 })
var event: ListChangeEvent<Int>? = null
disposer.add(list.observeListChange {
assertNull(event)
event = it
})
run {
dep.replaceAll(listOf(1, 2, 3, 4, 5))
val e = event
assertNotNull(e)
assertEquals(1, e.changes.size)
val c = e.changes.first()
assertEquals(0, c.index)
assertEquals(0, c.removed.size)
assertEquals(2, c.inserted.size)
assertEquals(2, c.inserted[0])
assertEquals(4, c.inserted[1])
}
event = null
run {
dep.splice(2, 2, 10)
val e = event
assertNotNull(e)
assertEquals(1, e.changes.size)
val c = e.changes.first()
assertEquals(1, c.index)
assertEquals(1, c.removed.size)
assertEquals(4, c.removed[0])
assertEquals(1, c.inserted.size)
assertEquals(10, c.inserted[0])
}
}
@Test
fun value_changes_and_emits_when_predicate_changes() = test {
val predicate: SimpleCell<(Int) -> Boolean> = SimpleCell { it % 2 == 0 }
val list = SimpleFilteredListCell(ImmutableListCell(listOf(1, 2, 3, 4, 5)), predicate)
var event: ListChangeEvent<Int>? = null
disposer.add(list.observeListChange {
assertNull(event)
event = it
})
run {
// Change predicate.
predicate.value = { it % 2 == 1 }
// Value changes.
assertEquals(listOf(1, 3, 5), list.value)
// An event was emitted.
val e = event
assertNotNull(e)
assertEquals(1, e.changes.size)
val c = e.changes.first()
assertEquals(0, c.index)
assertEquals(listOf(2, 4), c.removed)
assertEquals(listOf(1, 3, 5), c.inserted)
}
event = null
run {
// Change predicate.
predicate.value = { it % 2 == 0 }
// Value changes.
assertEquals(listOf(2, 4), list.value)
// An event was emitted.
val e = event
assertNotNull(e)
assertEquals(1, e.changes.size)
val c = e.changes.first()
assertEquals(0, c.index)
assertEquals(listOf(1, 3, 5), c.removed)
assertEquals(listOf(2, 4), c.inserted)
}
}
@Test
fun emits_correctly_when_multiple_changes_happen_at_once() = test {
val dependency = object : AbstractListCell<Int>() {
private val changes: MutableList<Pair<Int, Int>> = mutableListOf()
override val elements: MutableList<Int> = mutableListOf()
override val value = elements
override fun emitDependencyChanged() {
emitDependencyChangedEvent(ListChangeEvent(
elementsWrapper,
changes.map { (index, newElement) ->
ListChange(
index = index,
prevSize = index,
removed = emptyList(),
inserted = listOf(newElement),
)
}
))
changes.clear()
}
fun makeChanges(newElements: List<Int>) {
emitMightChange()
for (newElement in newElements) {
changes.add(Pair(elements.size, newElement))
elements.add(newElement)
}
ChangeManager.changed(this)
}
}
val list = SimpleFilteredListCell(dependency, ImmutableCell { true })
var event: ListChangeEvent<Int>? = null
disposer.add(list.observeListChange {
assertNull(event)
event = it
})
for (i in 1..3) {
event = null
// Make two changes at once everytime.
val change0 = i * 13
val change1 = i * 17
val changes = listOf(change0, change1)
val oldList = list.value.toList()
dependency.makeChanges(changes)
// These checks are very implementation-specific. At some point the filtered list might,
// for example, emit an event with a single change instead of two changes and then this
// test will incorrectly fail.
val e = event
assertNotNull(e)
assertEquals(oldList + changes, e.value)
assertEquals(2, e.changes.size)
val lc0 = e.changes[0]
assertEquals(oldList.size, lc0.index)
assertEquals(oldList.size, lc0.prevSize)
assertTrue(lc0.removed.isEmpty())
assertEquals(listOf(change0), lc0.inserted)
val lc1 = e.changes[1]
assertEquals(oldList.size + 1, lc1.index)
assertEquals(oldList.size + 1, lc1.prevSize)
assertTrue(lc1.removed.isEmpty())
assertEquals(listOf(change1), lc1.inserted)
}
}
@Test
fun emits_correctly_when_dependency_contains_same_element_twice() = test {
val x = "x"
val y = "y"
val z = "z"
val dependency = SimpleListCell(mutableListOf(x, y, z, x, y, z))
val list = SimpleFilteredListCell(dependency, SimpleCell { it != y })
var event: ListChangeEvent<String>? = null
disposer.add(list.observeListChange {
assertNull(event)
event = it
})
assertEquals(listOf(x, z, x, z), list.value)
run {
// Remove second x element.
dependency.removeAt(3)
// Value changes.
assertEquals(listOf(x, z, z), list.value)
// An event was emitted.
val e = event
assertNotNull(e)
assertEquals(1, e.changes.size)
val c = e.changes.first()
assertEquals(2, c.index)
assertEquals(listOf(x), c.removed)
assertTrue(c.inserted.isEmpty())
}
event = null
run {
// Remove first x element.
dependency.removeAt(0)
// Value changes.
assertEquals(listOf(z, z), list.value)
// An event was emitted.
val e = event
assertNotNull(e)
assertEquals(1, e.changes.size)
val c = e.changes.first()
assertEquals(0, c.index)
assertEquals(listOf(x), c.removed)
assertTrue(c.inserted.isEmpty())
}
event = null
run {
// Remove second z element.
dependency.removeAt(3)
// Value changes.
assertEquals(listOf(z), list.value)
// An event was emitted.
val e = event
assertNotNull(e)
assertEquals(1, e.changes.size)
val c = e.changes.first()
assertEquals(1, c.index)
assertEquals(listOf(z), c.removed)
assertTrue(c.inserted.isEmpty())
}
}
}

View File

@ -8,7 +8,16 @@ class DependentListCellTests : ListCellTests, CellWithDependenciesTests {
override fun createListProvider(empty: Boolean) = Provider(empty)
class Provider(empty: Boolean) : ListCellTests.Provider, CellWithDependenciesTests.Provider {
override fun createWithDependencies(
dependency1: Cell<Int>,
dependency2: Cell<Int>,
dependency3: Cell<Int>,
) =
DependentListCell(dependency1, dependency2, dependency3) {
listOf(dependency1.value, dependency2.value, dependency3.value)
}
class Provider(empty: Boolean) : ListCellTests.Provider {
private val dependencyCell =
SimpleListCell(if (empty) mutableListOf() else mutableListOf(5))
@ -18,8 +27,5 @@ class DependentListCellTests : ListCellTests, CellWithDependenciesTests {
override fun addElement() {
dependencyCell.add(4)
}
override fun createWithDependencies(vararg dependencies: Cell<Int>): Cell<Any> =
DependentListCell(*dependencies) { dependencies.map { it.value } }
}
}

View File

@ -0,0 +1,41 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.DependentCell
import world.phantasmal.observable.cell.ImmutableCell
// TODO: A test suite that tests FilteredListCell while its predicate dependency is changing.
// TODO: A test suite that tests FilteredListCell while the predicate results are changing.
class FilteredListCellListDependencyEmitsTests : AbstractFilteredListCellTests {
override fun createListProvider(empty: Boolean) = object : ListCellTests.Provider {
private val dependencyCell =
SimpleListCell(if (empty) mutableListOf(5) else mutableListOf(5, 10))
override val observable =
FilteredListCell(
list = dependencyCell,
predicate = ImmutableCell { ImmutableCell(it % 2 == 0) },
)
override fun addElement() {
dependencyCell.add(4)
}
}
override fun createWithDependencies(
dependency1: Cell<Int>,
dependency2: Cell<Int>,
dependency3: Cell<Int>,
) =
FilteredListCell(
list = DependentListCell(dependency1, computeElements = {
listOf(dependency1.value)
}),
predicate = DependentCell(dependency2, compute = {
fun predicate(element: Int) =
DependentCell(dependency3, compute = { element < dependency2.value })
::predicate
}),
)
}

View File

@ -1,179 +0,0 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.cell.SimpleCell
import kotlin.test.*
class FilteredListCellTests : ListCellTests {
override fun createListProvider(empty: Boolean) = object : ListCellTests.Provider {
private val dependencyCell =
SimpleListCell(if (empty) mutableListOf(5) else mutableListOf(5, 10))
override val observable = FilteredListCell(dependencyCell, predicate = { it % 2 == 0 })
override fun addElement() {
dependencyCell.add(4)
}
}
@Test
fun contains_only_values_that_match_the_predicate() = test {
val dep = SimpleListCell(mutableListOf("a", "b"))
val list = FilteredListCell(dep, predicate = { 'a' in it })
assertEquals(1, list.value.size)
assertEquals("a", list.value[0])
dep.add("foo")
dep.add("bar")
assertEquals(2, list.value.size)
assertEquals("a", list.value[0])
assertEquals("bar", list.value[1])
dep.add("quux")
dep.add("qaax")
assertEquals(3, list.value.size)
assertEquals("a", list.value[0])
assertEquals("bar", list.value[1])
assertEquals("qaax", list.value[2])
}
@Test
fun only_emits_when_necessary() = test {
val dep = SimpleListCell<Int>(mutableListOf())
val list = FilteredListCell(dep, predicate = { it % 2 == 0 })
var changes = 0
var listChanges = 0
disposer.add(list.observeChange {
changes++
})
disposer.add(list.observeListChange {
listChanges++
})
dep.add(1)
dep.add(3)
dep.add(5)
assertEquals(0, changes)
assertEquals(0, listChanges)
dep.add(0)
dep.add(2)
dep.add(4)
assertEquals(3, changes)
assertEquals(3, listChanges)
}
@Test
fun emits_correct_change_events() = test {
val dep = SimpleListCell<Int>(mutableListOf())
val list = FilteredListCell(dep, predicate = { it % 2 == 0 })
var event: ListChangeEvent<Int>? = null
disposer.add(list.observeListChange {
assertNull(event)
event = it
})
dep.replaceAll(listOf(1, 2, 3, 4, 5))
run {
val e = event
assertNotNull(e)
assertEquals(1, e.changes.size)
val c = e.changes.first()
assertTrue(c is ListChange.Structural)
assertEquals(0, c.index)
assertEquals(0, c.removed.size)
assertEquals(2, c.inserted.size)
assertEquals(2, c.inserted[0])
assertEquals(4, c.inserted[1])
}
event = null
dep.splice(2, 2, 10)
run {
val e = event
assertNotNull(e)
assertEquals(1, e.changes.size)
val c = e.changes.first()
assertTrue(c is ListChange.Structural)
assertEquals(1, c.index)
assertEquals(1, c.removed.size)
assertEquals(4, c.removed[0])
assertEquals(1, c.inserted.size)
assertEquals(10, c.inserted[0])
}
}
/**
* When the dependency of a [FilteredListCell] emits [ListChange.Element] changes, the
* [FilteredListCell] should emit either [ListChange.Structural] or [ListChange.Element]
* changes, depending on whether the predicate result has changed.
*/
@Test
fun emits_correct_events_when_dependency_emits_element_changes() = test {
val dep = SimpleListCell(
mutableListOf(SimpleCell(1), SimpleCell(2), SimpleCell(3), SimpleCell(4)),
extractDependencies = { arrayOf(it) },
)
val list = FilteredListCell(dep, predicate = { it.value % 2 == 0 })
var event: ListChangeEvent<SimpleCell<Int>>? = null
disposer.add(list.observeListChange {
assertNull(event)
event = it
})
for (i in 0 until dep.size.value) {
event = null
// Make an even number odd or an odd number even. List should emit a structural change.
val newValue = dep[i].value + 1
dep[i].value = newValue
val e = event
assertNotNull(e)
assertEquals(1, e.changes.size)
val c = e.changes.first()
assertTrue(c is ListChange.Structural)
if (newValue % 2 == 0) {
assertEquals(0, c.removed.size)
assertEquals(1, c.inserted.size)
assertEquals(newValue, c.inserted[0].value)
} else {
assertEquals(1, c.removed.size)
assertEquals(0, c.inserted.size)
assertEquals(newValue, c.removed[0].value)
}
}
for (i in 0 until dep.size.value) {
event = null
// Change a value, but keep even numbers even and odd numbers odd. List should emit an
// ElementChange event.
val newValue = dep[i].value + 2
dep[i].value = newValue
val e = event
assertNotNull(e)
assertEquals(1, e.changes.size)
val c = e.changes.first()
assertTrue(c is ListChange.Element)
assertEquals(newValue, c.updated.value)
}
}
}

View File

@ -1,6 +1,7 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.CellTests
import world.phantasmal.observable.cell.CellWithDependenciesTests
import world.phantasmal.observable.cell.ImmutableCell
@ -15,7 +16,16 @@ class FlatteningDependentListCellTransitiveDependencyEmitsTests :
override fun createListProvider(empty: Boolean) = Provider(empty)
class Provider(empty: Boolean) : ListCellTests.Provider, CellWithDependenciesTests.Provider {
override fun createWithDependencies(
dependency1: Cell<Int>,
dependency2: Cell<Int>,
dependency3: Cell<Int>,
) =
FlatteningDependentListCell(dependency1, dependency2, dependency3) {
ImmutableListCell(listOf(dependency1.value, dependency2.value, dependency3.value))
}
class Provider(empty: Boolean) : ListCellTests.Provider, CellTests.Provider {
// The transitive dependency can change.
private val transitiveDependency =
SimpleListCell(if (empty) mutableListOf() else mutableListOf(7))
@ -30,10 +40,5 @@ class FlatteningDependentListCellTransitiveDependencyEmitsTests :
// Update the transitive dependency.
transitiveDependency.add(4)
}
override fun createWithDependencies(vararg dependencies: Cell<Int>): Cell<Any> =
FlatteningDependentListCell(*dependencies) {
ImmutableListCell(dependencies.map { it.value })
}
}
}

View File

@ -211,8 +211,8 @@ interface ListCellTests : CellTests {
p.addElement()
assertNotNull(firstOrNull.value)
// Observer should not be called when adding elements at the end of the list.
assertNull(observedValue)
// Observer may or may not be called when adding elements at the end of the list.
assertTrue(observedValue == null || observedValue == firstOrNull.value)
}
}

View File

@ -0,0 +1,43 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.cell.*
/**
* In these tests, the direct list cell dependency of the [ListElementsDependentCell] doesn't
* change, but its elements do change.
*/
class ListElementsDependentCellElementEmitsTests : CellWithDependenciesTests {
override fun createProvider() = object : CellTests.Provider {
// One transitive dependency can change.
private val transitiveDependency = Element(2)
// The direct dependency of the list under test can't change.
private val directDependency: ListCell<Element> =
ImmutableListCell(listOf(Element(1), transitiveDependency, Element(3)))
override val observable =
ListElementsDependentCell(directDependency) { arrayOf(it.int, it.double, it.string) }
override fun emit() {
transitiveDependency.int.value++
}
}
override fun createWithDependencies(
dependency1: Cell<Int>,
dependency2: Cell<Int>,
dependency3: Cell<Int>,
) =
ListElementsDependentCell(
ImmutableListCell(listOf(dependency1, dependency2, dependency3))
) { arrayOf(it) }
private class Element(value: Int) {
val int: MutableCell<Int> = SimpleCell(value)
val double: Cell<Double> = DependentCell(int) { int.value.toDouble() }
val string: Cell<String> = DependentCell(int) { int.value.toString() }
override fun toString(): String = "Element[int=$int, double=$double, string=$string]"
}
}

View File

@ -0,0 +1,25 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.CellTests
import world.phantasmal.observable.cell.ImmutableCell
/**
* In these tests, the direct list cell dependency of the [ListElementsDependentCell] changes, while
* its elements don't change.
*/
class ListElementsDependentCellListCellEmitsTests : CellTests {
override fun createProvider() = object : CellTests.Provider {
// The direct dependency of the list under test changes, its elements are immutable.
private val directDependency: SimpleListCell<Cell<Int>> =
SimpleListCell(mutableListOf(ImmutableCell(1), ImmutableCell(2), ImmutableCell(3)))
override val observable =
ListElementsDependentCell(directDependency) { arrayOf(it) }
override fun emit() {
directDependency.add(ImmutableCell(directDependency.value.size + 1))
}
}
}

View File

@ -0,0 +1,56 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.cell.SimpleCell
import world.phantasmal.observable.test.ObservableTestSuite
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertNull
/**
* Standard tests are done by [ListElementsDependentCellElementEmitsTests] and
* [ListElementsDependentCellListCellEmitsTests].
*/
class ListElementsDependentCellTests : ObservableTestSuite {
@Test
fun element_changes_are_correctly_propagated() = test {
val list = SimpleListCell(
mutableListOf(
SimpleCell("a"),
SimpleCell("b"),
SimpleCell("c")
)
)
val cell = ListElementsDependentCell(list) { arrayOf(it) }
var event: ChangeEvent<*>? = null
disposer.add(cell.observeChange {
assertNull(event)
event = it
})
// The cell should not emit events when an old element is changed.
run {
val removed = list.removeAt(1)
event = null
removed.value += "-1"
assertNull(event)
}
// The cell should emit events when any of the current elements are changed.
list.add(SimpleCell("d"))
for (element in list.value) {
event = null
element.value += "-2"
val e = event
assertNotNull(e)
}
}
}

View File

@ -34,7 +34,6 @@ interface MutableListCellTests<T : Any> : ListCellTests, MutableCellTests<List<T
assertEquals(1, e.changes.size)
val c0 = e.changes[0]
assertTrue(c0 is ListChange.Structural)
assertEquals(0, c0.index)
assertTrue(c0.removed.isEmpty())
assertEquals(1, c0.inserted.size)
@ -57,7 +56,6 @@ interface MutableListCellTests<T : Any> : ListCellTests, MutableCellTests<List<T
assertEquals(1, e.changes.size)
val c0 = e.changes[0]
assertTrue(c0 is ListChange.Structural)
assertEquals(1, c0.index)
assertTrue(c0.removed.isEmpty())
assertEquals(1, c0.inserted.size)
@ -81,7 +79,6 @@ interface MutableListCellTests<T : Any> : ListCellTests, MutableCellTests<List<T
assertEquals(1, e.changes.size)
val c0 = e.changes[0]
assertTrue(c0 is ListChange.Structural)
assertEquals(1, c0.index)
assertTrue(c0.removed.isEmpty())
assertEquals(1, c0.inserted.size)

View File

@ -0,0 +1,39 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.DependentCell
import world.phantasmal.observable.cell.ImmutableCell
/**
* In these tests the list dependency of the [SimpleListCell] changes and the predicate
* dependency does not.
*/
class SimpleFilteredListCellListDependencyEmitsTests : AbstractFilteredListCellTests {
override fun createListProvider(empty: Boolean) = object : ListCellTests.Provider {
private val dependencyCell =
SimpleListCell(if (empty) mutableListOf(5) else mutableListOf(5, 10))
override val observable = SimpleFilteredListCell(
list = dependencyCell,
predicate = ImmutableCell { it % 2 == 0 },
)
override fun addElement() {
dependencyCell.add(4)
}
}
override fun createWithDependencies(
dependency1: Cell<Int>,
dependency2: Cell<Int>,
dependency3: Cell<Int>,
) =
SimpleFilteredListCell(
list = DependentListCell(dependency1, dependency2) {
listOf(dependency1.value, dependency2.value)
},
predicate = DependentCell(dependency3) {
{ it < dependency3.value }
},
)
}

View File

@ -0,0 +1,41 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.DependentCell
import world.phantasmal.observable.cell.SimpleCell
/**
* In these tests the predicate dependency of the [SimpleListCell] changes and the list dependency
* does not.
*/
class SimpleFilteredListCellPredicateDependencyEmitsTests : AbstractFilteredListCellTests {
override fun createListProvider(empty: Boolean) = object : ListCellTests.Provider {
private var maxValue = if (empty) 0 else 1
private val predicateCell = SimpleCell<(Int) -> Boolean> { it <= maxValue }
override val observable = SimpleFilteredListCell(
list = ImmutableListCell((1..20).toList()),
predicate = predicateCell,
)
override fun addElement() {
maxValue++
val max = maxValue
predicateCell.value = { it <= max }
}
}
override fun createWithDependencies(
dependency1: Cell<Int>,
dependency2: Cell<Int>,
dependency3: Cell<Int>,
) =
SimpleFilteredListCell(
list = DependentListCell(dependency1, dependency2) {
listOf(dependency1.value, dependency2.value)
},
predicate = DependentCell(dependency3) {
{ it < dependency3.value }
},
)
}

View File

@ -1,9 +1,10 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.cell.SimpleCell
import world.phantasmal.observable.test.assertListCellEquals
import world.phantasmal.testUtils.TestContext
import kotlin.test.*
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SimpleListCellTests : MutableListCellTests<Int> {
override fun createProvider() = createListProvider(empty = true)
@ -31,15 +32,8 @@ class SimpleListCellTests : MutableListCellTests<Int> {
@Test
fun set() = test {
testSet(SimpleListCell(mutableListOf("a", "b", "c")))
}
val list = SimpleListCell(mutableListOf("a", "b", "c"))
@Test
fun set_with_extractDependencies() = test {
testSet(SimpleListCell(mutableListOf("a", "b", "c")) { arrayOf() })
}
private fun testSet(list: SimpleListCell<String>) {
list[1] = "test"
list[2] = "test2"
assertFailsWith<IndexOutOfBoundsException> {
@ -147,133 +141,4 @@ class SimpleListCellTests : MutableListCellTests<Int> {
// List should remain unchanged after invalid calls.
assertListCellEquals(listOf(101, 0, 1, 2, 100, 8, 9, 102), list)
}
@Test
fun element_changes_propagate_correctly_after_set() = test {
testElementChangePropagation {
val old = it[1]
it[1] = SimpleCell("new")
listOf(old)
}
}
@Test
fun element_changes_propagate_correctly_after_add() = test {
testElementChangePropagation {
it.add(SimpleCell("new"))
emptyList()
}
}
@Test
fun element_changes_propagate_correctly_after_add_with_index() = test {
testElementChangePropagation {
it.add(1, SimpleCell("new"))
emptyList()
}
}
@Test
fun element_changes_propagate_correctly_after_remove() = test {
testElementChangePropagation {
val removed = it[1]
it.remove(removed)
listOf(removed)
}
}
@Test
fun element_changes_propagate_correctly_after_removeAt() = test {
testElementChangePropagation {
listOf(it.removeAt(2))
}
}
@Test
fun element_changes_propagate_correctly_after_replaceAll() = test {
testElementChangePropagation {
val removed = it.value.toList()
it.replaceAll(listOf(SimpleCell("new a"), SimpleCell("new b")))
removed
}
}
@Test
fun element_changes_propagate_correctly_after_replaceAll_with_sequence() = test {
testElementChangePropagation {
val removed = it.value.toList()
it.replaceAll(sequenceOf(SimpleCell("new a"), SimpleCell("new b")))
removed
}
}
@Test
fun element_changes_propagate_correctly_after_splice() = test {
testElementChangePropagation {
val removed = it.value.toList().drop(1)
it.splice(1, 2, SimpleCell("new"))
removed
}
}
@Test
fun element_changes_propagate_correctly_after_clear() = test {
testElementChangePropagation {
val removed = it.value.toList()
it.clear()
removed
}
}
/**
* Creates a list with 3 SimpleCells as elements, calls [updateList] with this list and then
* checks that changes to old elements don't affect the list and changes to new elements do
* affect the list.
*
* @param updateList Function that changes the list and returns the removed elements if any.
*/
private fun TestContext.testElementChangePropagation(
updateList: (SimpleListCell<SimpleCell<String>>) -> List<SimpleCell<String>>
) {
val list = SimpleListCell(
mutableListOf(
SimpleCell("a"),
SimpleCell("b"),
SimpleCell("c")
)
) { arrayOf(it) }
var event: ListChangeEvent<SimpleCell<String>>? = null
disposer.add(list.observeListChange {
assertNull(event)
event = it
})
val removed = updateList(list)
event = null
// The list should not emit events when an old element is changed.
for (element in removed) {
element.value += "-1"
assertNull(event)
}
// The list should emit events when any of the current elements are changed.
for ((index, element) in list.value.withIndex()) {
event = null
element.value += "-2"
val e = event
assertNotNull(e)
assertEquals(1, e.changes.size)
val c = e.changes.first()
assertTrue(c is ListChange.Element)
assertEquals(index, c.index)
assertEquals(element, c.updated)
}
}
}

View File

@ -1,9 +1,6 @@
package world.phantasmal.web.huntOptimizer.controllers
import world.phantasmal.observable.cell.list.ListCell
import world.phantasmal.observable.cell.list.filtered
import world.phantasmal.observable.cell.list.listCell
import world.phantasmal.observable.cell.list.sortedWith
import world.phantasmal.observable.cell.list.*
import world.phantasmal.observable.cell.mutableCell
import world.phantasmal.psolib.Episode
import world.phantasmal.psolib.fileFormats.quest.NpcType
@ -27,9 +24,13 @@ class MethodsForEpisodeController(
override val fixedColumns = 2
override val values: ListCell<HuntMethodModel> by lazy {
huntMethodStore.methods
val methodsForEpisode = huntMethodStore.methods
.filtered { it.episode == episode }
.sortedWith(comparator)
.dependingOnElements { arrayOf(it.time) }
mapToList(methodsForEpisode, comparator) { list, cmp ->
list.sortedWith(cmp)
}
}
override val loadingStatus: LoadingStatusCell = huntMethodStore.methodsStatus

View File

@ -26,7 +26,7 @@ class HuntMethodStore(
private val assetLoader: AssetLoader,
private val huntMethodPersister: HuntMethodPersister,
) : Store() {
private val _methods = mutableListCell<HuntMethodModel> { arrayOf(it.time) }
private val _methods = mutableListCell<HuntMethodModel>()
private val _methodsStatus = LoadingStatusCellImpl(scope, "methods", ::loadMethods)
/** Hunting methods supported by the current server. */

View File

@ -12,8 +12,10 @@ import world.phantasmal.core.unsafe.UnsafeMap
import world.phantasmal.core.unsafe.UnsafeSet
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.list.ListCell
import world.phantasmal.observable.cell.list.dependingOnElements
import world.phantasmal.observable.cell.list.mutableListCell
import world.phantasmal.observable.cell.mutableCell
import world.phantasmal.observable.observe
import world.phantasmal.psolib.fileFormats.quest.NpcType
import world.phantasmal.web.core.models.Server
import world.phantasmal.web.core.stores.EnemyDropTable
@ -50,7 +52,7 @@ class HuntOptimizerStore(
private val itemDropStore: ItemDropStore,
) : Store() {
private val _huntableItems = mutableListCell<ItemType>()
private val _wantedItems = mutableListCell<WantedItemModel> { arrayOf(it.amount) }
private val _wantedItems = mutableListCell<WantedItemModel>()
private val _optimizationResult = mutableCell(OptimizationResultModel(emptyList(), emptyList()))
private var wantedItemsPersistenceObserver: Disposable? = null
@ -58,6 +60,7 @@ class HuntOptimizerStore(
observeNow(uiStore.server) { server ->
_huntableItems.clear()
// There's a race condition here.
scope.launch {
val dropTable = itemDropStore.getEnemyDropTable(server)
@ -76,9 +79,18 @@ class HuntOptimizerStore(
}
val optimizationResult: Cell<OptimizationResultModel> by lazy {
observeNow(wantedItems, huntMethodStore.methods) { wantedItems, huntMethods ->
observeNow(
_wantedItems.dependingOnElements { arrayOf(it.amount) },
huntMethodStore.methods.dependingOnElements { arrayOf(it.time) },
) { wantedItems, huntMethods ->
// There's a race condition here.
scope.launch(Dispatchers.Default) {
_optimizationResult.value = optimize(wantedItems, huntMethods)
val dropTable = itemDropStore.getEnemyDropTable(uiStore.server.value)
val result = optimize(wantedItems, huntMethods, dropTable)
withContext(Dispatchers.Main) {
_optimizationResult.value = result
}
}
}
@ -91,7 +103,7 @@ class HuntOptimizerStore(
}
fun addWantedItem(itemType: ItemType) {
if (wantedItems.value.none { it.itemType == itemType }) {
if (_wantedItems.value.none { it.itemType == itemType }) {
_wantedItems.add(WantedItemModel(itemType, 1))
}
}
@ -114,20 +126,20 @@ class HuntOptimizerStore(
_wantedItems.replaceAll(wantedItems)
// Wanted items are loaded, start observing them and persist whenever they change.
wantedItemsPersistenceObserver = _wantedItems.observeChange {
val items = it.value
scope.launch(Dispatchers.Main) {
wantedItemPersister.persistWantedItems(items, server)
wantedItemsPersistenceObserver =
_wantedItems.dependingOnElements { arrayOf(it.amount) }.observe { items ->
scope.launch(Dispatchers.Main) {
wantedItemPersister.persistWantedItems(items, server)
}
}
}
}
}
}
private suspend fun optimize(
private fun optimize(
wantedItems: List<WantedItemModel>,
methods: List<HuntMethodModel>,
dropTable: EnemyDropTable,
): OptimizationResultModel {
logger.debug { "Optimization start." }
@ -139,8 +151,6 @@ class HuntOptimizerStore(
return OptimizationResultModel(emptyList(), emptyList())
}
val dropTable = itemDropStore.getEnemyDropTable(uiStore.server.value)
// Add a constraint per wanted item.
val constraints: dynamic = obj {}

View File

@ -36,9 +36,9 @@ class QuestModel(
private val _shortDescription = mutableCell("")
private val _longDescription = mutableCell("")
private val _mapDesignations = mutableCell(mapDesignations)
private val _npcs = SimpleListCell(npcs) { arrayOf(it.sectionInitialized, it.wave) }
private val _objects = SimpleListCell(objects) { arrayOf(it.sectionInitialized) }
private val _events = SimpleListCell(events) { arrayOf(it.id) }
private val _npcs = SimpleListCell(npcs)
private val _objects = SimpleListCell(objects)
private val _events = SimpleListCell(events)
val id: Cell<Int> = _id
val language: Cell<Int> = _language

View File

@ -1,8 +1,11 @@
package world.phantasmal.web.questEditor.rendering
import world.phantasmal.observable.cell.and
import world.phantasmal.observable.cell.eq
import world.phantasmal.observable.cell.flatMapNull
import world.phantasmal.observable.cell.list.emptyListCell
import world.phantasmal.observable.cell.list.filtered
import world.phantasmal.observable.cell.list.filteredCell
import world.phantasmal.observable.cell.or
import world.phantasmal.web.questEditor.loading.AreaAssetLoader
import world.phantasmal.web.questEditor.loading.EntityAssetLoader
import world.phantasmal.web.questEditor.stores.QuestEditorStore
@ -28,10 +31,10 @@ class QuestEditorMeshManager(
) { quest, area, wave ->
loadNpcMeshes(
if (quest != null && area != null) {
quest.npcs.filtered {
it.sectionInitialized.value &&
it.areaId == area.id &&
(wave == null || it.wave.value == wave)
quest.npcs.filteredCell {
it.sectionInitialized and
(it.areaId == area.id) and
((wave == null) or (it.wave eq wave))
}
} else {
emptyListCell()
@ -45,8 +48,8 @@ class QuestEditorMeshManager(
) { quest, area ->
loadObjectMeshes(
if (quest != null && area != null) {
quest.objects.filtered {
it.sectionInitialized.value && it.areaId == area.id
quest.objects.filteredCell {
it.sectionInitialized and (it.areaId == area.id)
}
} else {
emptyListCell()

View File

@ -6,7 +6,6 @@ import kotlinx.coroutines.launch
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.DisposableSupervisedScope
import world.phantasmal.observable.cell.list.ListCell
import world.phantasmal.observable.cell.list.ListChange
import world.phantasmal.observable.cell.list.ListChangeEvent
import world.phantasmal.psolib.Episode
import world.phantasmal.web.questEditor.loading.AreaAssetLoader
@ -75,19 +74,15 @@ abstract class QuestMeshManager protected constructor(
private fun npcsChanged(event: ListChangeEvent<QuestNpcModel>) {
for (change in event.changes) {
if (change is ListChange.Structural) {
change.removed.forEach(npcMeshManager::remove)
change.inserted.forEach(npcMeshManager::add)
}
change.removed.forEach(npcMeshManager::remove)
change.inserted.forEach(npcMeshManager::add)
}
}
private fun objectsChanged(event: ListChangeEvent<QuestObjectModel>) {
for (change in event.changes) {
if (change is ListChange.Structural) {
change.removed.forEach(objectMeshManager::remove)
change.inserted.forEach(objectMeshManager::add)
}
change.removed.forEach(objectMeshManager::remove)
change.inserted.forEach(objectMeshManager::add)
}
}
}

View File

@ -39,7 +39,16 @@ class QuestRenderer(
),
)
observeNow(questEditorStore.currentQuest) { inputManager.resetCamera() }
observeNow(questEditorStore.currentAreaVariant) { inputManager.resetCamera() }
var prevQuest = questEditorStore.currentQuest.value
var prevAreaVariant = questEditorStore.currentAreaVariant.value
observeNow(questEditorStore.currentQuest, questEditorStore.currentAreaVariant) { q, av ->
if (q !== prevQuest || av !== prevAreaVariant) {
inputManager.resetCamera()
prevQuest = q
prevAreaVariant = av
}
}
}
}

View File

@ -209,14 +209,14 @@ class IdleState(
val entity: QuestEntityModel<*, *>,
/**
* Vector that points from the grabbing point (somewhere on the model's surface) to the entity's
* origin.
* Vector that points from the grabbing point (somewhere on the model's surface) to the
* entity's origin.
*/
val grabOffset: Vector3,
/**
* Vector that points from the grabbing point to the terrain point directly under the entity's
* origin.
* Vector that points from the grabbing point to the terrain point directly under the
* entity's origin.
*/
val dragAdjust: Vector3,
)

View File

@ -158,8 +158,8 @@ class QuestEditorStore(
updateQuestEntitySections(quest)
// Ensure all entities have their section initialized.
quest.npcs.value.forEach { it.setSectionInitialized() }
quest.objects.value.forEach { it.setSectionInitialized() }
quest.npcs.value.forEach(QuestNpcModel::setSectionInitialized)
quest.objects.value.forEach(QuestObjectModel::setSectionInitialized)
}
}

View File

@ -1,11 +1,13 @@
package world.phantasmal.web.questEditor.controllers
import world.phantasmal.observable.cell.observeNow
import world.phantasmal.web.questEditor.models.QuestEventActionModel
import world.phantasmal.web.questEditor.models.QuestEventModel
import world.phantasmal.web.test.WebTestSuite
import world.phantasmal.web.test.createQuestModel
import kotlin.test.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class EventsControllerTests : WebTestSuite {
@Test
@ -113,38 +115,27 @@ class EventsControllerTests : WebTestSuite {
(ctrl.events[0].actions[0] as QuestEventActionModel.TriggerEvent).eventId
)
// We test the observed value instead of the cell's value property.
var canGoToEventValue: Boolean? = null
disposer.add(canGoToEvent.observeNow {
assertNull(canGoToEventValue)
canGoToEventValue = it
})
assertEquals(true, canGoToEventValue)
assertEquals(true, canGoToEvent.value)
// Let event 100 point to nonexistent event 102.
canGoToEventValue = null
ctrl.setActionEventId(
ctrl.events[0],
ctrl.events[0].actions[0] as QuestEventActionModel.TriggerEvent,
102,
)
assertEquals(false, canGoToEventValue)
assertEquals(false, canGoToEvent.value)
// Add event 102.
canGoToEventValue = null
ctrl.selectEvent(null) // Deselect so the next event will be added at the end of the list.
ctrl.addEvent()
ctrl.setId(ctrl.events.value.last(), 102)
assertEquals(true, canGoToEventValue)
assertEquals(true, canGoToEvent.value)
// Remove event 102.
canGoToEventValue = null
ctrl.removeEvent(ctrl.events.value.last())
assertEquals(false, canGoToEventValue)
assertEquals(false, canGoToEvent.value)
}
}

View File

@ -14,7 +14,6 @@ import world.phantasmal.core.unsafe.UnsafeMap
import world.phantasmal.core.unsafe.getOrPut
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.list.ListCell
import world.phantasmal.observable.cell.list.ListChange
import world.phantasmal.observable.cell.list.ListChangeEvent
import world.phantasmal.webui.externals.fontawesome.IconDefinition
import world.phantasmal.webui.externals.fontawesome.freeBrandsSvgIcons.faGithub
@ -288,28 +287,26 @@ private fun <T> bindChildrenTo(
return list.observeListChange { event: ListChangeEvent<T> ->
for (change in event.changes) {
if (change is ListChange.Structural) {
if (change.allRemoved) {
parent.innerHTML = ""
} else {
repeat(change.removed.size) {
parent.removeChild(parent.childNodes[change.index].unsafeCast<Node>())
}
if (change.allRemoved) {
parent.innerHTML = ""
} else {
repeat(change.removed.size) {
parent.removeChild(parent.childNodes[change.index].unsafeCast<Node>())
}
}
childrenRemoved(change.index, change.removed.size)
childrenRemoved(change.index, change.removed.size)
val frag = document.createDocumentFragment()
val frag = document.createDocumentFragment()
change.inserted.forEachIndexed { i, value ->
frag.appendChild(frag.createChild(value, change.index + i))
}
change.inserted.forEachIndexed { i, value ->
frag.appendChild(frag.createChild(value, change.index + i))
}
if (change.index >= parent.childNodes.length) {
parent.appendChild(frag)
} else {
parent.insertBefore(frag, parent.childNodes[change.index])
}
if (change.index >= parent.childNodes.length) {
parent.appendChild(frag)
} else {
parent.insertBefore(frag, parent.childNodes[change.index])
}
}

View File

@ -72,7 +72,7 @@ class HTMLElementSizeCell(element: HTMLElement? = null) : AbstractCell<Size>() {
if (newValue != _value) {
emitMightChange()
_value = newValue
emitDependencyChanged(ChangeEvent(newValue))
emitDependencyChangedEvent(ChangeEvent(newValue))
}
}
}