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 typealias ChangeObserver<T> = (ChangeEvent<T>) -> Unit
open class ChangeEvent<out T>( open class ChangeEvent<out T>(
/** /** The observable's new value. */
* The observable's new value.
*/
val value: T, val value: T,
) { ) {
operator fun component1() = value 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] * know that it will actually change, just that it might change. Always call [dependencyChanged]
* after calling this method. * 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() 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) { if (mightChangeEmitted) {
mightChangeEmitted = false 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 dependenciesActuallyChanged = true
} }
if (--changingDependencies == 0) { changingDependencies--
if (changingDependencies == 0) {
if (dependenciesActuallyChanged) { if (dependenciesActuallyChanged) {
dependenciesActuallyChanged = false dependenciesActuallyChanged = false
dependenciesChanged() dependenciesFinishedChanging()
} else { } else {
emitDependencyChanged(null) emitDependencyChangedEvent(null)
} }
} }
} }
@ -35,5 +37,9 @@ abstract class AbstractDependentCell<T> : AbstractCell<T>(), Dependent {
// change set is being completed. // 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> = infix fun <T : Comparable<T>> Cell<T>.lt(other: Cell<T>): Cell<Boolean> =
map(this, other) { a, b -> a < b } 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> = infix fun Cell<Boolean>.and(other: Cell<Boolean>): Cell<Boolean> =
map(this, other) { a, b -> a && b } map(this, other) { a, b -> a && b }
infix fun Cell<Boolean>.and(other: Boolean): Cell<Boolean> = infix fun Cell<Boolean>.and(other: Boolean): Cell<Boolean> =
if (other) this else falseCell() 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> = infix fun Cell<Boolean>.or(other: Cell<Boolean>): Cell<Boolean> =
map(this, other) { a, b -> a || b } 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> = infix fun Cell<Boolean>.xor(other: Cell<Boolean>): Cell<Boolean> =
// Use != because of https://youtrack.jetbrains.com/issue/KT-31277. // Use != because of https://youtrack.jetbrains.com/issue/KT-31277.
map(this, other) { a, b -> a != b } map(this, other) { a, b -> a != b }

View File

@ -22,6 +22,6 @@ class DelegatingCell<T>(
} }
override fun emitDependencyChanged() { 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 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>( class DependentCell<T>(
private vararg val dependencies: Dependency, private vararg val dependencies: Dependency,
private val compute: () -> T private val compute: () -> T,
) : AbstractDependentCell<T>() { ) : AbstractDependentCell<T>() {
private var _value: T? = null private var _value: T? = null
override val value: T override val value: T
get() { 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()) { if (dependents.isEmpty()) {
_value = compute() _value = compute()
} }
@ -25,6 +28,9 @@ class DependentCell<T>(
override fun addDependent(dependent: Dependent) { override fun addDependent(dependent: Dependent) {
if (dependents.isEmpty()) { 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() _value = compute()
for (dependency in dependencies) { for (dependency in dependencies) {
@ -39,20 +45,16 @@ class DependentCell<T>(
super.removeDependent(dependent) super.removeDependent(dependent)
if (dependents.isEmpty()) { if (dependents.isEmpty()) {
// Stop actually depending on our dependencies when we no longer have any dependents.
for (dependency in dependencies) { for (dependency in dependencies) {
dependency.removeDependent(this) dependency.removeDependent(this)
} }
} }
} }
override fun dependenciesChanged() { override fun dependenciesFinishedChanging() {
val newValue = compute() val newValue = compute()
_value = newValue
if (newValue != _value) { emitDependencyChangedEvent(ChangeEvent(newValue))
_value = newValue
emitDependencyChanged(ChangeEvent(newValue))
} else {
emitDependencyChanged(null)
}
} }
} }

View File

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

View File

@ -2,10 +2,19 @@ package world.phantasmal.observable.cell
import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.nopDisposable import world.phantasmal.core.disposable.nopDisposable
import world.phantasmal.observable.AbstractDependency
import world.phantasmal.observable.ChangeObserver 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 observeChange(observer: ChangeObserver<T>): Disposable = nopDisposable()
override fun emitDependencyChanged() { override fun emitDependencyChanged() {

View File

@ -16,6 +16,6 @@ class SimpleCell<T>(value: T) : AbstractCell<T>(), MutableCell<T> {
} }
override fun emitDependencyChanged() { 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 = override fun observeListChange(observer: ListChangeObserver<E>): Disposable =
CallbackChangeObserver(this, observer) CallbackChangeObserver(this, observer)
final override fun dependenciesChanged() { final override fun dependenciesFinishedChanging() {
val oldElements = value val oldElements = value
computeElements() computeElements()
emitDependencyChanged( emitDependencyChangedEvent(
ListChangeEvent( ListChangeEvent(
elements, elements,
listOf(ListChange.Structural( listOf(ListChange(
index = 0, index = 0,
prevSize = oldElements.size, prevSize = oldElements.size,
removed = oldElements, 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> = override fun subList(fromIndex: Int, toIndex: Int): List<E> =
backingList.subList(fromIndex, toIndex) backingList.subList(fromIndex, toIndex)
@Suppress("SuspiciousEqualsCombination") override fun equals(other: Any?): Boolean = other == backingList
override fun equals(other: Any?): Boolean = this === other || other == backingList
override fun hashCode(): Int = backingList.hashCode() override fun hashCode(): Int = backingList.hashCode()

View File

@ -1,217 +1,212 @@
package world.phantasmal.observable.cell.list 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.ChangeEvent
import world.phantasmal.observable.Dependency import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent import world.phantasmal.observable.Dependent
import world.phantasmal.observable.cell.Cell
class FilteredListCell<E>( class FilteredListCell<E>(
private val dependency: ListCell<E>, list: ListCell<E>,
private val predicate: (E) -> Boolean, private val predicate: Cell<(E) -> Cell<Boolean>>,
) : AbstractListCell<E>(), Dependent { ) : 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. * 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> override val predicateDependency: Dependency
get() { get() = predicate
if (dependents.isEmpty()) {
recompute()
}
return elementsWrapper
}
override fun addDependent(dependent: Dependent) {
if (dependents.isEmpty()) {
dependency.addDependent(this)
recompute()
}
super.addDependent(dependent)
}
override fun removeDependent(dependent: Dependent) { override fun removeDependent(dependent: Dependent) {
super.removeDependent(dependent) super.removeDependent(dependent)
if (dependents.isEmpty()) { if (dependents.isEmpty()) {
dependency.removeDependent(this) for (mapping in indexMap) {
mapping.removeDependent(this)
}
} }
} }
override fun dependencyMightChange() { override fun otherDependencyChanged(dependency: Dependency) {
emitMightChange() assert { dependency is FilteredListCell<*>.Mapping }
changedPredicateResults.add(unsafeCast(dependency))
} }
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) { override fun ignoreOtherChanges() {
if (event is ListChangeEvent<*>) { changedPredicateResults.clear()
val prevSize = elements.size }
val filteredChanges = mutableListOf<ListChange<E>>()
for (change in event.changes) { override fun processOtherChanges(filteredChanges: MutableList<ListChange<E>>) {
when (change) { var shift = 0
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>
val removed = mutableListOf<E>() for ((dependencyIndex, mapping) in indexMap.withIndex()) {
var eventIndex = -1 if (changedPredicateResults.isEmpty()) {
break
}
change.removed.forEachIndexed { i, element -> if (changedPredicateResults.remove(mapping)) {
val index = indexMap[change.index + i] val result = mapping.predicateResult.value
val oldResult = mapping.index != -1
if (index != -1) { if (result != oldResult) {
removed.add(element) val prevSize = elements.size
if (eventIndex == -1) { if (result) {
eventIndex = index // 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 -> filteredChanges.add(ListChange(
val index = indexMap[change.index + i] index,
prevSize,
if (index != -1) { removed = listOf(element),
inserted.add(element) inserted = emptyList(),
))
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))
}
}
} }
} else if (oldResult) {
mapping.index += shift
} }
}
if (filteredChanges.isEmpty()) {
emitDependencyChanged(null)
} else { } 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() { override fun recompute() {
// 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() {
copyAndResetWrapper() copyAndResetWrapper()
elements.clear() elements.clear()
for (mapping in indexMap) {
mapping.removeDependent(this)
}
indexMap.clear() indexMap.clear()
dependency.value.forEach { element -> // Cache value here to facilitate loop unswitching.
if (predicate(element)) { val hasDependents = dependents.isNotEmpty()
elements.add(element) val pred = predicate.value
indexMap.add(elements.lastIndex)
} else { for (element in list.value) {
indexMap.add(-1) 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.Disposable
import world.phantasmal.core.disposable.nopDisposable import world.phantasmal.core.disposable.nopDisposable
import world.phantasmal.observable.AbstractDependency
import world.phantasmal.observable.ChangeObserver 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.cell import world.phantasmal.observable.cell.cell
import world.phantasmal.observable.cell.falseCell import world.phantasmal.observable.cell.falseCell
import world.phantasmal.observable.cell.trueCell 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 size: Cell<Int> = cell(elements.size)
override val empty: Cell<Boolean> = if (elements.isEmpty()) trueCell() else falseCell() override val empty: Cell<Boolean> = if (elements.isEmpty()) trueCell() else falseCell()
override val notEmpty: Cell<Boolean> = if (elements.isNotEmpty()) trueCell() else falseCell() override val notEmpty: Cell<Boolean> = if (elements.isNotEmpty()) trueCell() else falseCell()
override val value: List<E> = elements 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 = override fun get(index: Int): E =
elements[index] elements[index]

View File

@ -1,7 +1,9 @@
package world.phantasmal.observable.cell.list package world.phantasmal.observable.cell.list
import world.phantasmal.observable.Observable
import world.phantasmal.observable.cell.Cell import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.DependentCell import world.phantasmal.observable.cell.DependentCell
import world.phantasmal.observable.cell.ImmutableCell
private val EMPTY_LIST_CELL = ImmutableListCell<Nothing>(emptyList()) 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 fun <E> emptyListCell(): ListCell<E> = EMPTY_LIST_CELL
/** Returns a mutable list cell containing [elements]. */ /** Returns a mutable list cell containing [elements]. */
fun <E> mutableListCell( fun <E> mutableListCell(vararg elements: E): MutableListCell<E> =
vararg elements: E, SimpleListCell(mutableListOf(*elements))
extractDependencies: DependenciesExtractor<E>? = null,
): MutableListCell<E> = /**
SimpleListCell(mutableListOf(*elements), extractDependencies) * 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> = fun <E, R> ListCell<E>.listMap(transform: (E) -> R): ListCell<R> =
DependentListCell(this) { value.map(transform) } DependentListCell(this) { value.map(transform) }
@ -31,13 +42,16 @@ fun <E> ListCell<E>.sumOf(selector: (E) -> Int): Cell<Int> =
DependentCell(this) { value.sumOf(selector) } DependentCell(this) { value.sumOf(selector) }
fun <E> ListCell<E>.filtered(predicate: (E) -> Boolean): ListCell<E> = 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> = 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> = fun <E> ListCell<E>.filteredCell(predicate: (E) -> Cell<Boolean>): ListCell<E> =
DependentListCell(this, comparator) { value.sortedWith(comparator.value) } 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?> = fun <E> ListCell<E>.firstOrNull(): Cell<E?> =
DependentCell(this) { value.firstOrNull() } DependentCell(this) { value.firstOrNull() }

View File

@ -7,39 +7,19 @@ class ListChangeEvent<out E>(
val changes: List<ListChange<E>>, val changes: List<ListChange<E>>,
) : ChangeEvent<List<E>>(value) ) : 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.
* Represents a structural change to a list cell. E.g. an element is inserted or removed. */
*/ class ListChange<out E>(
class Structural<out E>( val index: Int,
val index: Int, val prevSize: Int,
val prevSize: Int, /** The elements that were removed from the list at [index]. */
/** val removed: List<E>,
* The elements that were removed from the list at [index]. /** The elements that were inserted into the list at [index]. */
* val inserted: List<E>,
* 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. /** True when this change resulted in the removal of all elements from the list. */
*/ val allRemoved: Boolean get() = removed.size == prevSize
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>()
} }
typealias ListChangeObserver<E> = (ListChangeEvent<E>) -> Unit 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 package world.phantasmal.observable.cell.list
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.replaceAll 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.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 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>( class SimpleListCell<E>(
override val elements: MutableList<E>, override val elements: MutableList<E>,
private val extractDependencies: DependenciesExtractor<E>? = null,
) : AbstractListCell<E>(), MutableListCell<E> { ) : 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>>() private var changes = mutableListOf<ListChange<E>>()
override var value: List<E> override var value: List<E>
@ -45,18 +28,12 @@ class SimpleListCell<E>(
copyAndResetWrapper() copyAndResetWrapper()
val removed = elements.set(index, element) val removed = elements.set(index, element)
if (dependents.isNotEmpty() && extractDependencies != null) { finalizeChange(
elementDependents[index].dispose()
elementDependents[index] = ElementDependent(index, element)
}
changes.add(ListChange.Structural(
index, index,
prevSize = elements.size, prevSize = elements.size,
removed = listOf(removed), removed = listOf(removed),
inserted = listOf(element), inserted = listOf(element),
)) )
ChangeManager.changed(this)
return removed return removed
} }
@ -68,7 +45,7 @@ class SimpleListCell<E>(
copyAndResetWrapper() copyAndResetWrapper()
elements.add(element) elements.add(element)
finalizeStructuralChange( finalizeChange(
index, index,
prevSize = index, prevSize = index,
removed = emptyList(), removed = emptyList(),
@ -84,7 +61,7 @@ class SimpleListCell<E>(
copyAndResetWrapper() copyAndResetWrapper()
elements.add(index, element) 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 { override fun remove(element: E): Boolean {
@ -107,7 +84,7 @@ class SimpleListCell<E>(
copyAndResetWrapper() copyAndResetWrapper()
val removed = elements.removeAt(index) val removed = elements.removeAt(index)
finalizeStructuralChange(index, prevSize, removed = listOf(removed), inserted = emptyList()) finalizeChange(index, prevSize, removed = listOf(removed), inserted = emptyList())
return removed return removed
} }
@ -120,7 +97,7 @@ class SimpleListCell<E>(
copyAndResetWrapper() copyAndResetWrapper()
this.elements.replaceAll(elements) this.elements.replaceAll(elements)
finalizeStructuralChange(index = 0, prevSize, removed, inserted = elementsWrapper) finalizeChange(index = 0, prevSize, removed, inserted = elementsWrapper)
} }
override fun replaceAll(elements: Sequence<E>) { override fun replaceAll(elements: Sequence<E>) {
@ -132,7 +109,7 @@ class SimpleListCell<E>(
copyAndResetWrapper() copyAndResetWrapper()
this.elements.replaceAll(elements) 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) { override fun splice(fromIndex: Int, removeCount: Int, newElement: E) {
@ -149,7 +126,7 @@ class SimpleListCell<E>(
repeat(removeCount) { elements.removeAt(fromIndex) } repeat(removeCount) { elements.removeAt(fromIndex) }
elements.add(fromIndex, newElement) elements.add(fromIndex, newElement)
finalizeStructuralChange(fromIndex, prevSize, removed, inserted = listOf(newElement)) finalizeChange(fromIndex, prevSize, removed, inserted = listOf(newElement))
} }
override fun clear() { override fun clear() {
@ -161,7 +138,7 @@ class SimpleListCell<E>(
copyAndResetWrapper() copyAndResetWrapper()
elements.clear() elements.clear()
finalizeStructuralChange(index = 0, prevSize, removed, inserted = emptyList()) finalizeChange(index = 0, prevSize, removed, inserted = emptyList())
} }
override fun sortWith(comparator: Comparator<E>) { override fun sortWith(comparator: Comparator<E>) {
@ -177,7 +154,7 @@ class SimpleListCell<E>(
throwable = e throwable = e
} }
finalizeStructuralChange( finalizeChange(
index = 0, index = 0,
prevSize = elements.size, prevSize = elements.size,
removed, 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() { override fun emitDependencyChanged() {
val currentChanges = changes val currentChanges = changes
changes = mutableListOf() changes = mutableListOf()
emitDependencyChanged(ListChangeEvent(elementsWrapper, currentChanges)) emitDependencyChangedEvent(ListChangeEvent(elementsWrapper, currentChanges))
} }
private fun checkIndex(index: Int, maxIndex: Int) { private fun checkIndex(index: Int, maxIndex: Int) {
@ -225,75 +180,13 @@ class SimpleListCell<E>(
} }
} }
private fun finalizeStructuralChange( private fun finalizeChange(
index: Int, index: Int,
prevSize: Int, prevSize: Int,
removed: List<E>, removed: List<E>,
inserted: List<E>, inserted: List<E>,
) { ) {
if (dependents.isNotEmpty() && extractDependencies != null) { changes.add(ListChange(index, prevSize, removed, inserted))
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))
ChangeManager.changed(this) 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 { interface Provider {
val dependency: Dependency val dependency: Dependency
/**
* Makes [dependency] emit [Dependent.dependencyMightChange] followed by
* [Dependent.dependencyChanged] with a non-null event.
*/
fun emit() fun emit()
} }
} }

View File

@ -51,12 +51,12 @@ interface CellTests : ObservableTests {
fun emits_correct_value_in_change_events() = test { fun emits_correct_value_in_change_events() = test {
val p = createProvider() val p = createProvider()
var prevValue: Any? var prevValue: Snapshot?
var observedValue: Any? = null var observedValue: Snapshot? = null
disposer.add(p.observable.observeChange { changeEvent -> disposer.add(p.observable.observeChange { changeEvent ->
assertNull(observedValue) assertNull(observedValue)
observedValue = changeEvent.value observedValue = changeEvent.value.snapshot()
}) })
repeat(3) { repeat(3) {
@ -69,7 +69,7 @@ interface CellTests : ObservableTests {
// it should be equal to the cell's current value. // it should be equal to the cell's current value.
assertNotNull(observedValue) assertNotNull(observedValue)
assertNotEquals(prevValue, 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 { fun reflects_changes_without_observers() = test {
val p = createProvider() val p = createProvider()
var old: Any? var old: Snapshot?
repeat(5) { repeat(5) {
// Value should change after emit. // Value should change after emit.
old = p.observable.value old = p.observable.value.snapshot()
p.emit() p.emit()
val new = p.observable.value val new = p.observable.value.snapshot()
assertNotEquals(old, new) assertNotEquals(old, new)
// Value should not change when emit hasn't been called since the last access. // 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 @Test
fun propagates_changes_to_mapped_cell() = test { fun propagates_changes_to_mapped_cell() = test {
val p = createProvider() val p = createProvider()
val mapped = p.observable.map { it.hashCode() } val mapped = p.observable.map { it.snapshot() }
val initialValue = mapped.value val initialValue = mapped.value
var observedValue: Int? = null var observedValue: Snapshot? = null
disposer.add(mapped.observeChange { changeEvent -> disposer.add(mapped.observeChange { changeEvent ->
assertNull(observedValue) assertNull(observedValue)
@ -140,10 +140,10 @@ interface CellTests : ObservableTests {
fun propagates_changes_to_flat_mapped_cell() = test { fun propagates_changes_to_flat_mapped_cell() = test {
val p = createProvider() val p = createProvider()
val mapped = p.observable.flatMap { ImmutableCell(it.hashCode()) } val mapped = p.observable.flatMap { ImmutableCell(it.snapshot()) }
val initialValue = mapped.value val initialValue = mapped.value
var observedValue: Int? = null var observedValue: Snapshot? = null
disposer.add(mapped.observeChange { disposer.add(mapped.observeChange {
assertNull(observedValue) assertNull(observedValue)
@ -160,3 +160,16 @@ interface CellTests : ObservableTests {
override val observable: Cell<Any> 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 package world.phantasmal.observable.cell
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent import world.phantasmal.observable.Dependent
import kotlin.test.Test import kotlin.test.*
import kotlin.test.assertEquals
import kotlin.test.assertTrue
interface CellWithDependenciesTests : CellTests { 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 @Test
fun is_recomputed_once_even_when_many_dependencies_change() = test { fun is_recomputed_once_even_when_many_dependencies_change() = test {
val p = createProvider()
val root = SimpleCell(5) val root = SimpleCell(5)
val branch1 = root.map { it * 2 } val branch1 = DependentCell(root) { root.value * 2 }
val branch2 = root.map { it * 4 } val branch2 = DependentCell(root) { root.value * 3 }
val leaf = p.createWithDependencies(branch1, branch2) val branch3 = DependentCell(root) { root.value * 4 }
val leaf = createWithDependencies(branch1, branch2, branch3)
var observedChanges = 0 var observedChanges = 0
@ -30,29 +70,31 @@ interface CellWithDependenciesTests : CellTests {
@Test @Test
fun doesnt_register_as_dependent_of_its_dependencies_until_it_has_dependents_itself() = 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 cell = createWithDependencies(dependency1, dependency2, dependency3)
val publicDependents: List<Dependent> = dependents
override val value: Int = 5 assertTrue(dependency1.publicDependents.isEmpty())
assertTrue(dependency2.publicDependents.isEmpty())
override fun emitDependencyChanged() { assertTrue(dependency3.publicDependents.isEmpty())
// Not going to change.
throw NotImplementedError()
}
}
val cell = p.createWithDependencies(dependency)
assertTrue(dependency.publicDependents.isEmpty())
disposer.add(cell.observeChange { }) 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 { private class TestCell : AbstractCell<Int>() {
fun createWithDependencies(vararg dependencies: Cell<Int>): Cell<Any> 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 } 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) private val dependencyCell = SimpleCell(1)
override val observable = DependentCell(dependencyCell) { 2 * dependencyCell.value } override val observable = DependentCell(dependencyCell) { 2 * dependencyCell.value }
@ -16,8 +25,5 @@ class DependentCellTests : RegularCellTests, CellWithDependenciesTests {
override fun emit() { override fun emit() {
dependencyCell.value += 2 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 } 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. // The transitive dependency can change.
private val transitiveDependency = SimpleCell(5) private val transitiveDependency = SimpleCell(5)
@ -28,8 +37,5 @@ class FlatteningDependentCellTransitiveDependencyEmitsTests :
// Update the transitive dependency. // Update the transitive dependency.
transitiveDependency.value += 5 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) 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 = private val dependencyCell =
SimpleListCell(if (empty) mutableListOf() else mutableListOf(5)) SimpleListCell(if (empty) mutableListOf() else mutableListOf(5))
@ -18,8 +27,5 @@ class DependentListCellTests : ListCellTests, CellWithDependenciesTests {
override fun addElement() { override fun addElement() {
dependencyCell.add(4) 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 package world.phantasmal.observable.cell.list
import world.phantasmal.observable.cell.Cell import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.CellTests
import world.phantasmal.observable.cell.CellWithDependenciesTests import world.phantasmal.observable.cell.CellWithDependenciesTests
import world.phantasmal.observable.cell.ImmutableCell import world.phantasmal.observable.cell.ImmutableCell
@ -15,7 +16,16 @@ class FlatteningDependentListCellTransitiveDependencyEmitsTests :
override fun createListProvider(empty: Boolean) = Provider(empty) 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. // The transitive dependency can change.
private val transitiveDependency = private val transitiveDependency =
SimpleListCell(if (empty) mutableListOf() else mutableListOf(7)) SimpleListCell(if (empty) mutableListOf() else mutableListOf(7))
@ -30,10 +40,5 @@ class FlatteningDependentListCellTransitiveDependencyEmitsTests :
// Update the transitive dependency. // Update the transitive dependency.
transitiveDependency.add(4) 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() p.addElement()
assertNotNull(firstOrNull.value) assertNotNull(firstOrNull.value)
// Observer should not be called when adding elements at the end of the list. // Observer may or may not be called when adding elements at the end of the list.
assertNull(observedValue) 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) assertEquals(1, e.changes.size)
val c0 = e.changes[0] val c0 = e.changes[0]
assertTrue(c0 is ListChange.Structural)
assertEquals(0, c0.index) assertEquals(0, c0.index)
assertTrue(c0.removed.isEmpty()) assertTrue(c0.removed.isEmpty())
assertEquals(1, c0.inserted.size) assertEquals(1, c0.inserted.size)
@ -57,7 +56,6 @@ interface MutableListCellTests<T : Any> : ListCellTests, MutableCellTests<List<T
assertEquals(1, e.changes.size) assertEquals(1, e.changes.size)
val c0 = e.changes[0] val c0 = e.changes[0]
assertTrue(c0 is ListChange.Structural)
assertEquals(1, c0.index) assertEquals(1, c0.index)
assertTrue(c0.removed.isEmpty()) assertTrue(c0.removed.isEmpty())
assertEquals(1, c0.inserted.size) assertEquals(1, c0.inserted.size)
@ -81,7 +79,6 @@ interface MutableListCellTests<T : Any> : ListCellTests, MutableCellTests<List<T
assertEquals(1, e.changes.size) assertEquals(1, e.changes.size)
val c0 = e.changes[0] val c0 = e.changes[0]
assertTrue(c0 is ListChange.Structural)
assertEquals(1, c0.index) assertEquals(1, c0.index)
assertTrue(c0.removed.isEmpty()) assertTrue(c0.removed.isEmpty())
assertEquals(1, c0.inserted.size) 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 package world.phantasmal.observable.cell.list
import world.phantasmal.observable.cell.SimpleCell
import world.phantasmal.observable.test.assertListCellEquals import world.phantasmal.observable.test.assertListCellEquals
import world.phantasmal.testUtils.TestContext import kotlin.test.Test
import kotlin.test.* import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SimpleListCellTests : MutableListCellTests<Int> { class SimpleListCellTests : MutableListCellTests<Int> {
override fun createProvider() = createListProvider(empty = true) override fun createProvider() = createListProvider(empty = true)
@ -31,15 +32,8 @@ class SimpleListCellTests : MutableListCellTests<Int> {
@Test @Test
fun set() = 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[1] = "test"
list[2] = "test2" list[2] = "test2"
assertFailsWith<IndexOutOfBoundsException> { assertFailsWith<IndexOutOfBoundsException> {
@ -147,133 +141,4 @@ class SimpleListCellTests : MutableListCellTests<Int> {
// List should remain unchanged after invalid calls. // List should remain unchanged after invalid calls.
assertListCellEquals(listOf(101, 0, 1, 2, 100, 8, 9, 102), list) 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 package world.phantasmal.web.huntOptimizer.controllers
import world.phantasmal.observable.cell.list.ListCell import world.phantasmal.observable.cell.list.*
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.mutableCell import world.phantasmal.observable.cell.mutableCell
import world.phantasmal.psolib.Episode import world.phantasmal.psolib.Episode
import world.phantasmal.psolib.fileFormats.quest.NpcType import world.phantasmal.psolib.fileFormats.quest.NpcType
@ -27,9 +24,13 @@ class MethodsForEpisodeController(
override val fixedColumns = 2 override val fixedColumns = 2
override val values: ListCell<HuntMethodModel> by lazy { override val values: ListCell<HuntMethodModel> by lazy {
huntMethodStore.methods val methodsForEpisode = huntMethodStore.methods
.filtered { it.episode == episode } .filtered { it.episode == episode }
.sortedWith(comparator) .dependingOnElements { arrayOf(it.time) }
mapToList(methodsForEpisode, comparator) { list, cmp ->
list.sortedWith(cmp)
}
} }
override val loadingStatus: LoadingStatusCell = huntMethodStore.methodsStatus override val loadingStatus: LoadingStatusCell = huntMethodStore.methodsStatus

View File

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

View File

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

View File

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

View File

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

View File

@ -39,7 +39,16 @@ class QuestRenderer(
), ),
) )
observeNow(questEditorStore.currentQuest) { inputManager.resetCamera() } var prevQuest = questEditorStore.currentQuest.value
observeNow(questEditorStore.currentAreaVariant) { inputManager.resetCamera() } 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<*, *>, val entity: QuestEntityModel<*, *>,
/** /**
* Vector that points from the grabbing point (somewhere on the model's surface) to the entity's * Vector that points from the grabbing point (somewhere on the model's surface) to the
* origin. * entity's origin.
*/ */
val grabOffset: Vector3, val grabOffset: Vector3,
/** /**
* Vector that points from the grabbing point to the terrain point directly under the entity's * Vector that points from the grabbing point to the terrain point directly under the
* origin. * entity's origin.
*/ */
val dragAdjust: Vector3, val dragAdjust: Vector3,
) )

View File

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

View File

@ -1,11 +1,13 @@
package world.phantasmal.web.questEditor.controllers 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.QuestEventActionModel
import world.phantasmal.web.questEditor.models.QuestEventModel import world.phantasmal.web.questEditor.models.QuestEventModel
import world.phantasmal.web.test.WebTestSuite import world.phantasmal.web.test.WebTestSuite
import world.phantasmal.web.test.createQuestModel 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 { class EventsControllerTests : WebTestSuite {
@Test @Test
@ -113,38 +115,27 @@ class EventsControllerTests : WebTestSuite {
(ctrl.events[0].actions[0] as QuestEventActionModel.TriggerEvent).eventId (ctrl.events[0].actions[0] as QuestEventActionModel.TriggerEvent).eventId
) )
// We test the observed value instead of the cell's value property. assertEquals(true, canGoToEvent.value)
var canGoToEventValue: Boolean? = null
disposer.add(canGoToEvent.observeNow {
assertNull(canGoToEventValue)
canGoToEventValue = it
})
assertEquals(true, canGoToEventValue)
// Let event 100 point to nonexistent event 102. // Let event 100 point to nonexistent event 102.
canGoToEventValue = null
ctrl.setActionEventId( ctrl.setActionEventId(
ctrl.events[0], ctrl.events[0],
ctrl.events[0].actions[0] as QuestEventActionModel.TriggerEvent, ctrl.events[0].actions[0] as QuestEventActionModel.TriggerEvent,
102, 102,
) )
assertEquals(false, canGoToEventValue) assertEquals(false, canGoToEvent.value)
// Add event 102. // Add event 102.
canGoToEventValue = null
ctrl.selectEvent(null) // Deselect so the next event will be added at the end of the list. ctrl.selectEvent(null) // Deselect so the next event will be added at the end of the list.
ctrl.addEvent() ctrl.addEvent()
ctrl.setId(ctrl.events.value.last(), 102) ctrl.setId(ctrl.events.value.last(), 102)
assertEquals(true, canGoToEventValue) assertEquals(true, canGoToEvent.value)
// Remove event 102. // Remove event 102.
canGoToEventValue = null
ctrl.removeEvent(ctrl.events.value.last()) 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.core.unsafe.getOrPut
import world.phantasmal.observable.cell.Cell import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.list.ListCell import world.phantasmal.observable.cell.list.ListCell
import world.phantasmal.observable.cell.list.ListChange
import world.phantasmal.observable.cell.list.ListChangeEvent import world.phantasmal.observable.cell.list.ListChangeEvent
import world.phantasmal.webui.externals.fontawesome.IconDefinition import world.phantasmal.webui.externals.fontawesome.IconDefinition
import world.phantasmal.webui.externals.fontawesome.freeBrandsSvgIcons.faGithub import world.phantasmal.webui.externals.fontawesome.freeBrandsSvgIcons.faGithub
@ -288,28 +287,26 @@ private fun <T> bindChildrenTo(
return list.observeListChange { event: ListChangeEvent<T> -> return list.observeListChange { event: ListChangeEvent<T> ->
for (change in event.changes) { for (change in event.changes) {
if (change is ListChange.Structural) { if (change.allRemoved) {
if (change.allRemoved) { parent.innerHTML = ""
parent.innerHTML = "" } else {
} else { repeat(change.removed.size) {
repeat(change.removed.size) { parent.removeChild(parent.childNodes[change.index].unsafeCast<Node>())
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 -> change.inserted.forEachIndexed { i, value ->
frag.appendChild(frag.createChild(value, change.index + i)) frag.appendChild(frag.createChild(value, change.index + i))
} }
if (change.index >= parent.childNodes.length) { if (change.index >= parent.childNodes.length) {
parent.appendChild(frag) parent.appendChild(frag)
} else { } else {
parent.insertBefore(frag, parent.childNodes[change.index]) parent.insertBefore(frag, parent.childNodes[change.index])
}
} }
} }

View File

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