diff --git a/model-datastructure/src/commonMain/kotlin/org/modelix/model/LinearHistory.kt b/model-datastructure/src/commonMain/kotlin/org/modelix/model/LinearHistory.kt index dc69849920..9f69e436aa 100644 --- a/model-datastructure/src/commonMain/kotlin/org/modelix/model/LinearHistory.kt +++ b/model-datastructure/src/commonMain/kotlin/org/modelix/model/LinearHistory.kt @@ -1,99 +1,138 @@ package org.modelix.model import org.modelix.model.lazy.CLVersion -import org.modelix.model.lazy.IDeserializingKeyValueStore -import org.modelix.model.lazy.KVEntryReference -import org.modelix.model.persistent.CPVersion -/** - * Was introduced in https://github.com/modelix/modelix/commit/19c74bed5921028af3ac3ee9d997fc1c4203ad44 - * together with the UndoOp. The idea is that an undo should only revert changes if there is no other change that relies - * on it. In that case the undo should do nothing, to not indirectly undo newer changes. - * For example, if you added a node and someone else started changing properties on the that node, your undo should not - * remove the node to not lose the property changes. - * This requires the versions to be ordered in a way that the undo appears later. - */ -class LinearHistory(val baseVersionHash: String?) { +class LinearHistory(private val baseVersionHash: String?) { + /** + * Children indexed by their parent versions. + * A version is a parent of a child, + * if the [CLVersion.baseVersion], the [CLVersion.getMergedVersion1] or [CLVersion.getMergedVersion2] + */ + private val byVersionChildren = mutableMapOf>() - val version2directDescendants: MutableMap> = HashMap() - val versions: MutableMap = LinkedHashMap() + /** + * Global roots are versions without parents. + * It may be only the version denoted [baseVersionHash] + * or many versions, if no base version was specified and versions without a common global root are ordered. + */ + private val globalRoot = mutableSetOf() /** - * @param fromVersions it is assumed that the versions are sorted by the oldest version first. When merging a new - * version into an existing one the new version should appear after the existing one. The resulting order - * will prefer existing versions to new ones, meaning during the conflict resolution the existing changes - * have a higher probability of surviving. - * @returns oldest version first + * The distance of a version from its root. + * Aka how many children a between the root and a version. + */ + private val byVersionDistanceFromGlobalRoot = mutableMapOf() + + /** + * Returns all versions between the [fromVersions] and a common version. + * The common version may be identified by [baseVersionHash]. + * If no [baseVersionHash] is given, the common version wile be the first version + * aka the version without a [CLVersion.baseVersion]. + * + * The order also ensures three properties: + * 1. The versions are ordered topologically starting with the versions without parents. + * 2. The order is also "monotonic". + * This means adding a version to the set of all versions will never change + * the order of versions that were previously in the history. + * For example, given versions 1, 2 and 3: + * If 1 and 2 are ordered as (1, 2), ordering 1, 2 and 3 will never produce (2, 3, 1). + * 3 can come anywhere (respecting the topological ordering), but 2 has to come after 1. + * 3. "Close versions are kept together" + * Formally: A version that has only one child (ignoring) should always come before the child. + * Example: 1 <- 2 <- 3 and 1 <- x, then [1, 2, 4, 3] is not allowed, + * because 3 is the only child of 2. + * Valid orders would be (1, x, 3, 4) and (1, x, 2, 3) + * This is relevant for UnduOp and RedoOp. + * See UndoTest. */ fun load(vararg fromVersions: CLVersion): List { - for (fromVersion in fromVersions) { - collect(fromVersion) - } + // Traverse the versions once to index need data: + // * Collect all relevant versions. + // * Collect the distance to the base for each version. + // * Collect the roots of relevant versions. + // * Collect the children of each version. + indexData(*fromVersions) - var result: List = emptyList() + // The following algorithm orders the version by + // 1. Finding out the roots of so-called subtrees. + // A subtree is a tree of all versions that have the same version as root ancestor. + // A root ancestor of a version is the first ancestor in the chain of ancestors + // that is either a merge or a global root. + // Each version belongs to exactly one root ancestor, and it will be the same (especially future merges). + // 2. Sort the roots of subtrees according to their distance (primary) and id (secondary) + // 3. Order topologically inside each subtree. - for (version in versions.values.filter { !it.isMerge() }.sortedBy { it.id }) { - val descendantIds = collectAllDescendants(version.id).filter { !versions[it]!!.isMerge() }.sorted().toSet() - val idsInResult = result.toHashSet() - if (idsInResult.contains(version.id)) { - result = - result + - descendantIds.filter { !idsInResult.contains(it) } - } else { - result = - result.filter { !descendantIds.contains(it) } + - version.id + - result.filter { descendantIds.contains(it) } + - descendantIds.filter { !idsInResult.contains(it) } - } - } - return result.map { versions[it]!! } - } + // Ordering the subtree root first, ensures the order is also "monotonic". + // Then ordering the inside subtree ensures "close versions are kept together" without breaking "monotonicity". + // Ordering inside a subtree ensures "monotonicity", because a subtree has no merges. + // Only a subtrees root can be a merge. - private fun collectAllDescendants(root: Long): Set { - val result = LinkedHashSet() - var previousSize = 0 - result += root + // Sorting the subtree roots by distance from base ensures topological order. + val comparator = compareBy(byVersionDistanceFromGlobalRoot::getValue) + // Sorting the subtree roots by distance from base and then by id ensures "monotonic" order. + .thenBy(CLVersion::id) + val rootsOfSubtreesToVisit = globalRoot + byVersionDistanceFromGlobalRoot.keys.filter(CLVersion::isMerge) + val orderedRootsOfSubtree = rootsOfSubtreesToVisit.distinct().sortedWith(comparator) - while (previousSize != result.size) { - val nextElements = result.asSequence().drop(previousSize).toList() - previousSize = result.size - for (ancestor in nextElements) { - version2directDescendants[ancestor]?.let { result += it } + val history = orderedRootsOfSubtree.flatMap { rootOfSubtree -> + val historyOfSubtree = mutableListOf() + val stack = ArrayDeque() + stack.add(rootOfSubtree) + while (stack.isNotEmpty()) { + val version = stack.removeLast() + historyOfSubtree.add(version) + val children = byVersionChildren.getOrElse(version, ::emptyList) + val childrenWithoutMerges = children.filterNot(CLVersion::isMerge) + // Order so that child with the lowest id is processed first + // and comes first in the history. + stack.addAll(childrenWithoutMerges.sortedByDescending(CLVersion::id)) } + historyOfSubtree } - - return result.drop(1).toSet() + return history.filterNot(CLVersion::isMerge) } - private fun collect(root: CLVersion) { - if (root.getContentHash() == baseVersionHash) return - - var previousSize = versions.size - versions[root.id] = root - - while (previousSize != versions.size) { - val nextElements = versions.asSequence().drop(previousSize).map { it.value }.toList() - previousSize = versions.size - - for (descendant in nextElements) { - val ancestors = if (descendant.isMerge()) { - sequenceOf( - getVersion(descendant.data!!.mergedVersion1!!, descendant.store), - getVersion(descendant.data!!.mergedVersion2!!, descendant.store), - ) + private fun indexData(vararg fromVersions: CLVersion): MutableMap { + val stack = ArrayDeque() + fromVersions.forEach { fromVersion -> + if (byVersionDistanceFromGlobalRoot.contains(fromVersion)) { + return@forEach + } + stack.addLast(fromVersion) + while (stack.isNotEmpty()) { + val version = stack.last() + val parents = version.getParents() + // Version is the base version or the first version and therfore a root. + if (parents.isEmpty()) { + stack.removeLast() + globalRoot.add(version) + byVersionDistanceFromGlobalRoot[version] = 0 } else { - sequenceOf(descendant.baseVersion) - }.filterNotNull().filter { it.getContentHash() != baseVersionHash }.toList() - for (ancestor in ancestors) { - versions[ancestor.id] = ancestor - version2directDescendants[ancestor.id] = (version2directDescendants[ancestor.id] ?: emptySet()) + setOf(descendant.id) + parents.forEach { parent -> + byVersionChildren.getOrPut(parent, ::mutableSetOf).add(version) + } + val (visitedParents, notVisitedParents) = parents.partition(byVersionDistanceFromGlobalRoot::contains) + // All children where already visited and have their distance known. + if (notVisitedParents.isEmpty()) { + stack.removeLast() + val depth = visitedParents.maxOf { byVersionDistanceFromGlobalRoot[it]!! } + 1 + byVersionDistanceFromGlobalRoot[version] = depth + // Children need to be visited + } else { + stack.addAll(notVisitedParents) + } } } } + return byVersionDistanceFromGlobalRoot } - private fun getVersion(hash: KVEntryReference, store: IDeserializingKeyValueStore): CLVersion { - return CLVersion(hash.getValue(store), store) + private fun CLVersion.getParents(): List { + val ancestors = if (isMerge()) { + listOf(getMergedVersion1()!!, getMergedVersion2()!!) + } else { + listOfNotNull(baseVersion) + } + return ancestors.filter { it.getContentHash() != baseVersionHash } } } diff --git a/model-datastructure/src/commonMain/kotlin/org/modelix/model/lazy/CLVersion.kt b/model-datastructure/src/commonMain/kotlin/org/modelix/model/lazy/CLVersion.kt index 1d2e8c0118..24612206ec 100644 --- a/model-datastructure/src/commonMain/kotlin/org/modelix/model/lazy/CLVersion.kt +++ b/model-datastructure/src/commonMain/kotlin/org/modelix/model/lazy/CLVersion.kt @@ -23,10 +23,14 @@ import org.modelix.model.IKeyListener import org.modelix.model.IKeyValueStore import org.modelix.model.IVersion import org.modelix.model.LinearHistory +import org.modelix.model.api.IIdGenerator import org.modelix.model.api.INodeReference +import org.modelix.model.api.IWriteTransaction import org.modelix.model.api.LocalPNodeReference import org.modelix.model.api.PNodeReference +import org.modelix.model.api.TreePointer import org.modelix.model.operations.IOperation +import org.modelix.model.operations.OTBranch import org.modelix.model.operations.SetReferenceOp import org.modelix.model.persistent.CPHamtNode import org.modelix.model.persistent.CPNode @@ -432,3 +436,16 @@ private class AccessTrackingStore(val store: IKeyValueStore) : IKeyValueStore { TODO("Not yet implemented") } } + +fun CLVersion.runWrite(idGenerator: IIdGenerator, author: String?, body: (IWriteTransaction) -> Unit): CLVersion { + val branch = OTBranch(TreePointer(getTree(), idGenerator), idGenerator, store) + branch.computeWriteT(body) + val (ops, newTree) = branch.getPendingChanges() + return CLVersion.createRegularVersion( + id = idGenerator.generate(), + author = author, + tree = newTree as CLTree, + baseVersion = this, + operations = ops.map { it.getOriginalOp() }.toTypedArray(), + ) +} diff --git a/model-datastructure/src/commonMain/kotlin/org/modelix/model/operations/OTBranch.kt b/model-datastructure/src/commonMain/kotlin/org/modelix/model/operations/OTBranch.kt index 894cfb1531..6bac8bf1bf 100644 --- a/model-datastructure/src/commonMain/kotlin/org/modelix/model/operations/OTBranch.kt +++ b/model-datastructure/src/commonMain/kotlin/org/modelix/model/operations/OTBranch.kt @@ -33,6 +33,7 @@ class OTBranch( private var currentOperations: MutableList = ArrayList() private val completedChanges: MutableList = ArrayList() private val id: String = branch.getId() + private var inWriteTransaction = false fun operationApplied(op: IAppliedOperation) { check(canWrite()) { "Only allowed inside a write transaction" } @@ -84,18 +85,20 @@ class OTBranch( override fun computeWrite(computable: () -> T): T { checkNotEDT() - return if (canWrite()) { - // Already in a transaction. Just append changes to the active one. - branch.computeWrite(computable) - } else { - branch.computeWriteT { t -> + return branch.computeWriteT { t -> + // canWrite() cannot be used as the condition, because that may statically return true (see TreePointer) + if (inWriteTransaction) { + computable() + } else { try { + inWriteTransaction = true val result = computable() runSynchronized(completedChanges) { completedChanges += OpsAndTree(currentOperations, t.tree) } result } finally { + inWriteTransaction = false currentOperations = ArrayList() } } diff --git a/model-datastructure/src/commonTest/kotlin/ConflictResolutionTest.kt b/model-datastructure/src/commonTest/kotlin/ConflictResolutionTest.kt index 90c9a242f0..596054372c 100644 --- a/model-datastructure/src/commonTest/kotlin/ConflictResolutionTest.kt +++ b/model-datastructure/src/commonTest/kotlin/ConflictResolutionTest.kt @@ -815,35 +815,35 @@ class ConflictResolutionTest : TreeTestBase() { operations = opsAndTree.first.map { it.getOriginalOp() }.toTypedArray(), ) } +} - fun assertSameTree(tree1: ITree, tree2: ITree) { - tree2.visitChanges( - tree1, - object : ITreeChangeVisitorEx { - override fun containmentChanged(nodeId: Long) { - fail("containmentChanged ${nodeId.toString(16)}") - } +fun assertSameTree(tree1: ITree, tree2: ITree) { + tree2.visitChanges( + tree1, + object : ITreeChangeVisitorEx { + override fun containmentChanged(nodeId: Long) { + fail("containmentChanged ${nodeId.toString(16)}") + } - override fun childrenChanged(nodeId: Long, role: String?) { - fail("childrenChanged ${nodeId.toString(16)}, $role") - } + override fun childrenChanged(nodeId: Long, role: String?) { + fail("childrenChanged ${nodeId.toString(16)}, $role") + } - override fun referenceChanged(nodeId: Long, role: String) { - fail("referenceChanged ${nodeId.toString(16)}, $role") - } + override fun referenceChanged(nodeId: Long, role: String) { + fail("referenceChanged ${nodeId.toString(16)}, $role") + } - override fun propertyChanged(nodeId: Long, role: String) { - fail("propertyChanged ${nodeId.toString(16)}, $role") - } + override fun propertyChanged(nodeId: Long, role: String) { + fail("propertyChanged ${nodeId.toString(16)}, $role") + } - override fun nodeRemoved(nodeId: Long) { - fail("nodeRemoved ${nodeId.toString(16)}") - } + override fun nodeRemoved(nodeId: Long) { + fail("nodeRemoved ${nodeId.toString(16)}") + } - override fun nodeAdded(nodeId: Long) { - fail("nodeAdded nodeId") - } - }, - ) - } + override fun nodeAdded(nodeId: Long) { + fail("nodeAdded nodeId") + } + }, + ) } diff --git a/model-datastructure/src/commonTest/kotlin/LinearHistoryTest.kt b/model-datastructure/src/commonTest/kotlin/LinearHistoryTest.kt index 38d5c67a66..3c53cbd439 100644 --- a/model-datastructure/src/commonTest/kotlin/LinearHistoryTest.kt +++ b/model-datastructure/src/commonTest/kotlin/LinearHistoryTest.kt @@ -23,6 +23,7 @@ import org.modelix.model.operations.IOperation import org.modelix.model.persistent.MapBaseStore import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class LinearHistoryTest { val initialTree = CLTree.builder(ObjectStoreCache(MapBaseStore())).repositoryId("LinearHistoryTest").build() @@ -44,6 +45,18 @@ class LinearHistoryTest { assertHistory(v20, v21, listOf(v20, v21)) } + @Test + fun divergedWithTwoCommitsInCommonBase() { + val v1 = version(1, null) + val v10 = version(10, v1) + val v20 = version(20, v10) + val v21 = version(21, v10) + + val actual = LinearHistory(null).load(v20, v21).map { it.id } + val expected = listOf(1L, 10L, 20, 21) + assertEquals(expected, actual) + } + @Test fun knownPerformanceIssue() { // This test was dumped from actual case discovered during a profiling session. @@ -152,17 +165,17 @@ class LinearHistoryTest { v1000004d5, v200000353, v1000004d8, + v200000356, + v200000359, v1000004dc, v1000004df, + v20000035d, + v20000035f, v1000004e2, + v200000362, v1000004e5, v1000004e7, v1000004e9, - v200000356, - v200000359, - v20000035d, - v20000035f, - v200000362, v200000365, v200000367, v200000369, @@ -171,15 +184,68 @@ class LinearHistoryTest { assertHistory(v300000075, v1000004ee, expected) } + @Test + fun correctHistoryIfIdsAreNotAscending() { + val v1 = version(1, null) + val v2 = version(2, v1) + val v3 = version(3, v1) + val v9 = version(9, v2) + val v4 = merge(4, v2, v3) + val v8 = version(8, v9) + + val expected = listOf(v2, v9, v8, v3) + assertHistory(v4, v8, expected) + } + private fun assertHistory(v1: CLVersion, v2: CLVersion, expected: List) { - val actual = history(v1, v2) - assertEquals(expected.map { it.id.toString(16) }, actual.map { it.id.toString(16) }) - assertEquals(expected, actual) + val historyMergingFirstIntoSecond = history(v1, v2) + val historyMeringSecondIntoFirst = history(v2, v1) + assertEquals( + historyMergingFirstIntoSecond.map { it.id.toString(16) }, + historyMeringSecondIntoFirst.map { it.id.toString(16) }, + ) + assertEquals(expected.map { it.id.toString(16) }, historyMergingFirstIntoSecond.map { it.id.toString(16) }) } private fun history(v1: CLVersion, v2: CLVersion): List { val base = VersionMerger.commonBaseVersion(v1, v2) - return LinearHistory(base?.getContentHash()).load(v1, v2) + val history = LinearHistory(base?.getContentHash()).load(v1, v2) + assertHistoryIsCorrect(history) + return history + } + + private fun assertHistoryIsCorrect(history: List) { + history.forEach { version -> + val versionIndex = history.indexOf(version) + getDescendants(version).forEach { descendent -> + val descendantIndex = history.indexOf(descendent) + // A descendant might not be in history + // (1) if it is a merge or + // (2) if it is a descendant of a common version. + // The descendantIndex is then -1. + assertTrue( + versionIndex > descendantIndex, + "${version.id.toString(16)} must come after its descendant ${descendent.id.toString(16)} in ${history.map { it.id.toString() }} .", + ) + } + } + } + + private fun getChildren(version: CLVersion): List { + return if (version.isMerge()) { + listOf(version.getMergedVersion1()!!, version.getMergedVersion2()!!) + } else { + listOfNotNull(version.baseVersion) + } + } + + private fun getDescendants(version: CLVersion): MutableSet { + val descendants = mutableSetOf() + getChildren(version).forEach { descendant -> + descendants.add(descendant) + descendants.addAll(getDescendants(descendant)) + } + return descendants } private fun version(id: Long, base: CLVersion?): CLVersion { diff --git a/model-datastructure/src/commonTest/kotlin/MergeOrderTest.kt b/model-datastructure/src/commonTest/kotlin/MergeOrderTest.kt new file mode 100644 index 0000000000..92e9caf5fc --- /dev/null +++ b/model-datastructure/src/commonTest/kotlin/MergeOrderTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2023. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.modelix.model.VersionMerger +import org.modelix.model.api.IIdGenerator +import org.modelix.model.api.ITree +import org.modelix.model.api.IWriteTransaction +import org.modelix.model.client.IdGenerator +import org.modelix.model.lazy.CLTree +import org.modelix.model.lazy.CLVersion +import org.modelix.model.lazy.ObjectStoreCache +import org.modelix.model.lazy.runWrite +import org.modelix.model.persistent.MapBaseStore +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +/** + * This tests indirectly the algorithm in LinearHistory, by creating and merging versions. + */ +class MergeOrderTest { + private var store: MapBaseStore = MapBaseStore() + private var storeCache: ObjectStoreCache = ObjectStoreCache(store) + private var idGenerator: IIdGenerator = IdGenerator.newInstance(3) + + fun CLVersion.runWrite(id: Long, body: IWriteTransaction.() -> Unit): CLVersion { + return nextId(id) { runWrite(idGenerator, null, { it.apply(body) }) } + } + fun CLVersion.runWrite(body: IWriteTransaction.() -> Unit) = runWrite(idGenerator, null, { it.apply(body) }) + fun merge(v1: CLVersion, v2: CLVersion) = VersionMerger(storeCache, idGenerator).mergeChange(v1, v2) + fun merge(id: Long, v1: CLVersion, v2: CLVersion) = nextId(id) { VersionMerger(storeCache, idGenerator).mergeChange(v1, v2) } + + @Test + fun mergeOrderShouldNotMatter() = mergeOrderShouldNotMatter(false) + + @Test + fun mergeOrderShouldNotMatterAlternativeIds() = mergeOrderShouldNotMatter(true) + + fun mergeOrderShouldNotMatter(alternativeIds: Boolean) { + val merger = VersionMerger(storeCache, idGenerator) + + val v0 = CLVersion.createRegularVersion( + idGenerator.generate(), + null, + null, + CLTree(storeCache), + null, + emptyArray(), + ) + + // There are two clients working on the same version and both change the same property. + // This creates a conflict. + val va1 = v0.runWrite(0xa1) { setProperty(ITree.ROOT_ID, "name", "MyClassA") } + assertEquals("MyClassA", va1.getTree().getProperty(ITree.ROOT_ID, "name")) + val vb1 = v0.runWrite(0xb1) { setProperty(ITree.ROOT_ID, "name", "MyClassB") } + assertEquals("MyClassB", vb1.getTree().getProperty(ITree.ROOT_ID, "name")) + + // Now both clients exchange their modifications ... + + // In the meantime, Client A keeps working on his branch and creates a new version that doesn't actually + // change anything, so should not have an effect on the following merge. + // It changes an unrelated part of the model that wouldn't cause a conflict even if the operations were ordered + // in a completely unexpected way. + val va2 = va1.runWrite(0xa2L + (if (alternativeIds) 0x100 else 0)) { + setProperty(ITree.ROOT_ID, "unrelated", "Class renamed") + setProperty(ITree.ROOT_ID, "unrelated", null) + } + assertEquals("MyClassA", va2.getTree().getProperty(ITree.ROOT_ID, "name")) + + // Client B receives the version with the first property change from client A, but not the second empty change. + val vb2 = merge(0xb2, vb1, va1) + assertContains(setOf("MyClassA", "MyClassB"), vb2.getTree().getProperty(ITree.ROOT_ID, "name")) + + // Client A receives the version with the property change from client B. + val va3 = merge(0xa3, va2, vb1) + assertContains(setOf("MyClassA", "MyClassB"), va3.getTree().getProperty(ITree.ROOT_ID, "name")) + + // The history should now look like this: + // + // v0 + // / \ + // va1 vb1 + // | \ / | + // va2 /\ | + // | / \ | + // |/ \ | + // va3 vb2 + // + // - va1 and vb2 contain the property change + // - va2 didn't change anything + // - va3 and vb2 are the current head versions that merged both property changes, + // and are expected to contain the same resulting model. + + // After Client B receives another update from Client A that includes the empty change va2 + // there shouldn't be any doubt that the merge result has to be identical ... + assertEquals( + va3.getTree().getProperty(ITree.ROOT_ID, "name"), + merger.mergeChange(vb2, va2).getTree().getProperty(ITree.ROOT_ID, "name"), + ) + assertSameTree(va3.getTree(), merger.mergeChange(vb2, va2).getTree()) + + // ... but even the previous merge should have an identical result. + // It would be confusing for the user if the merge algorithm keeps changing its mind about the result for + // no obvious reason. + assertEquals( + va3.getTree().getProperty(ITree.ROOT_ID, "name"), + vb2.getTree().getProperty(ITree.ROOT_ID, "name"), + ) + assertSameTree(va3.getTree(), vb2.getTree()) + } + + fun nextId(id: Long, body: () -> R): R { + val saved = idGenerator + try { + idGenerator = object : IIdGenerator { + override fun generate(): Long = id + } + return body() + } finally { + idGenerator = saved + } + } +} diff --git a/model-datastructure/src/commonTest/kotlin/UndoTest.kt b/model-datastructure/src/commonTest/kotlin/UndoTest.kt index dc2565f33a..1a6c8d2b4c 100644 --- a/model-datastructure/src/commonTest/kotlin/UndoTest.kt +++ b/model-datastructure/src/commonTest/kotlin/UndoTest.kt @@ -36,6 +36,30 @@ class UndoTest { @Test fun undo_random() { + /* + digraph G { + 1 [label="id=1"] + 2 [label="id=2\nv[0]"] + 3 [label="id=3\nv[1]"] + 4 [label="id=4\nv[2]"] + undo [label="id=5\nversion_1_1u\n>>undo v[1]<<"] + version_0_1 [label="id=30064771077\nversion_0_1"] + version_2_1 [label="id=30064771078\nversion_2_1"] + + 2 -> 1 + 3 -> 1 + 4 -> 1 + undo -> 3 + version_0_1 -> 2 + version_0_1 -> 3 + version_2_1 -> 3 + version_2_1 -> 4 + version_0_1_1u -> version_0_1 + version_0_1_1u -> undo + version_2_1_1u -> undo + version_2_1_1u -> version_2_1 + } + */ val idGenerator = IdGenerator.newInstance(7) val versionIdGenerator = IdGenerator.newInstance(0) val store = ObjectStoreCache(MapBaseStore())