diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9123b65dc..ac446a0f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,9 +94,7 @@ jobs: ram-size: 4096M emulator-boot-timeout: 12000 disable-animations: true - script: | - ./gradlew yorkie:connectedCheck -Pandroid.testInstrumentationRunnerArguments.notAnnotation=androidx.test.filters.LargeTest --no-build-cache --no-daemon --stacktrace - ./gradlew yorkie:connectedCheck -Pandroid.testInstrumentationRunnerArguments.annotation=androidx.test.filters.LargeTest --no-build-cache --no-daemon --stacktrace + script: ./gradlew yorkie:connectedCheck --no-build-cache --no-daemon --stacktrace - if: ${{ matrix.api-level == 30 }} run: ./gradlew yorkie:jacocoDebugTestReport - if: ${{ matrix.api-level == 30 }} diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index 5313b0289..4b2366f0b 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -15,7 +15,7 @@ services: depends_on: - yorkie yorkie: - image: 'yorkieteam/yorkie:0.4.6' + image: 'yorkieteam/yorkie:0.4.5' container_name: 'yorkie' command: [ 'server', diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7f4b860fd..2f5c71af8 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -22,7 +22,7 @@ services: extra_hosts: - "host.docker.internal:host-gateway" yorkie: - image: 'yorkieteam/yorkie:0.4.6' + image: 'yorkieteam/yorkie:0.4.5' container_name: 'yorkie' command: [ 'server', diff --git a/examples/texteditor/src/main/java/com/example/texteditor/EditorViewModel.kt b/examples/texteditor/src/main/java/com/example/texteditor/EditorViewModel.kt index 665ed9a75..e7f8cbdbf 100644 --- a/examples/texteditor/src/main/java/com/example/texteditor/EditorViewModel.kt +++ b/examples/texteditor/src/main/java/com/example/texteditor/EditorViewModel.kt @@ -30,9 +30,8 @@ class EditorViewModel(private val client: Client) : ViewModel(), YorkieEditText. private val _content = MutableSharedFlow() val content = _content.asSharedFlow() - private val _textOperationInfos = - MutableSharedFlow>() - val textOpInfos = _textOperationInfos.asSharedFlow() + private val _textOpInfos = MutableSharedFlow>() + val textOpInfos = _textOpInfos.asSharedFlow() val removedPeers = document.events.filterIsInstance() .map { it.unwatched.actorID } @@ -74,7 +73,7 @@ class EditorViewModel(private val client: Client) : ViewModel(), YorkieEditText. private suspend fun emitEditOpInfos(changeInfo: Document.Event.ChangeInfo) { changeInfo.operations.filterIsInstance() .forEach { opInfo -> - _textOperationInfos.emit(changeInfo.actorID to opInfo) + _textOpInfos.emit(changeInfo.actorID to opInfo) } } @@ -86,7 +85,7 @@ class EditorViewModel(private val client: Client) : ViewModel(), YorkieEditText. gson.fromJson(jsonArray.getString(1), TextPosStructure::class.java) ?: return val (from, to) = document.getRoot().getAs(TEXT_KEY) .posRangeToIndexRange(fromPos to toPos) - _textOperationInfos.emit(actorID to OperationInfo.SelectOpInfo(from, to)) + _textOpInfos.emit(actorID to OperationInfo.SelectOpInfo(from, to)) } fun syncText() { diff --git a/yorkie/proto/src/main/proto/yorkie/v1/resources.proto b/yorkie/proto/src/main/proto/yorkie/v1/resources.proto index 281b5906d..46d879aad 100644 --- a/yorkie/proto/src/main/proto/yorkie/v1/resources.proto +++ b/yorkie/proto/src/main/proto/yorkie/v1/resources.proto @@ -95,10 +95,6 @@ message Operation { TimeTicket executed_at = 6; map attributes = 7; } - // NOTE(hackerwins): Select Operation is not used in the current version. - // In the previous version, it was used to represent selection of Text. - // However, it has been replaced by Presence now. It is retained for backward - // compatibility purposes. message Select { TimeTicket parent_created_at = 1; TextNodePos from = 2; @@ -121,9 +117,8 @@ message Operation { TimeTicket parent_created_at = 1; TreePos from = 2; TreePos to = 3; - map created_at_map_by_actor = 4; - repeated TreeNodes contents = 5; - TimeTicket executed_at = 6; + repeated TreeNodes contents = 4; + TimeTicket executed_at = 5; } message TreeStyle { TimeTicket parent_created_at = 1; @@ -238,30 +233,24 @@ message TextNodeID { } message TreeNode { - TreeNodeID id = 1; + TreePos pos = 1; string type = 2; string value = 3; TimeTicket removed_at = 4; - TreeNodeID ins_prev_id = 5; - TreeNodeID ins_next_id = 6; - int32 depth = 7; - map attributes = 8; + TreePos ins_prev_pos = 5; + int32 depth = 6; + map attributes = 7; } message TreeNodes { repeated TreeNode content = 1; } -message TreeNodeID { +message TreePos { TimeTicket created_at = 1; int32 offset = 2; } -message TreePos { - TreeNodeID parent_id = 1; - TreeNodeID left_sibling_id = 2; -} - ///////////////////////////////////////// // Messages for Common // ///////////////////////////////////////// @@ -354,12 +343,12 @@ enum ValueType { } enum DocEventType { - DOC_EVENT_TYPE_DOCUMENT_CHANGED = 0; - DOC_EVENT_TYPE_DOCUMENT_WATCHED = 1; - DOC_EVENT_TYPE_DOCUMENT_UNWATCHED = 2; + DOC_EVENT_TYPE_DOCUMENTS_CHANGED = 0; + DOC_EVENT_TYPE_DOCUMENTS_WATCHED = 1; + DOC_EVENT_TYPE_DOCUMENTS_UNWATCHED = 2; } message DocEvent { DocEventType type = 1; - string publisher = 2; + bytes publisher = 2; } diff --git a/yorkie/proto/src/main/proto/yorkie/v1/yorkie.proto b/yorkie/proto/src/main/proto/yorkie/v1/yorkie.proto index 639ddd37d..b4f0eda6a 100644 --- a/yorkie/proto/src/main/proto/yorkie/v1/yorkie.proto +++ b/yorkie/proto/src/main/proto/yorkie/v1/yorkie.proto @@ -42,45 +42,49 @@ message ActivateClientRequest { } message ActivateClientResponse { - string client_id = 1; + string client_key = 1; + bytes client_id = 2; } message DeactivateClientRequest { - string client_id = 1; + bytes client_id = 1; } message DeactivateClientResponse { + bytes client_id = 1; } message AttachDocumentRequest { - string client_id = 1; + bytes client_id = 1; ChangePack change_pack = 2; } message AttachDocumentResponse { - string document_id = 1; - ChangePack change_pack = 2; + bytes client_id = 1; + string document_id = 2; + ChangePack change_pack = 3; } message DetachDocumentRequest { - string client_id = 1; + bytes client_id = 1; string document_id = 2; ChangePack change_pack = 3; bool remove_if_not_attached = 4; } message DetachDocumentResponse { + string client_key = 1; ChangePack change_pack = 2; } message WatchDocumentRequest { - string client_id = 1; + bytes client_id = 1; string document_id = 2; } message WatchDocumentResponse { message Initialization { - repeated string client_ids = 1; + repeated bytes client_ids = 1; } oneof body { @@ -90,22 +94,24 @@ message WatchDocumentResponse { } message RemoveDocumentRequest { - string client_id = 1; + bytes client_id = 1; string document_id = 2; ChangePack change_pack = 3; } message RemoveDocumentResponse { - ChangePack change_pack = 1; + string client_key = 1; + ChangePack change_pack = 2; } message PushPullChangesRequest { - string client_id = 1; + bytes client_id = 1; string document_id = 2; ChangePack change_pack = 3; bool push_only = 4; } message PushPullChangesResponse { - ChangePack change_pack = 1; + bytes client_id = 1; + ChangePack change_pack = 2; } diff --git a/yorkie/src/androidTest/kotlin/dev/yorkie/core/ClientTest.kt b/yorkie/src/androidTest/kotlin/dev/yorkie/core/ClientTest.kt index f245d4c03..4c296bb87 100644 --- a/yorkie/src/androidTest/kotlin/dev/yorkie/core/ClientTest.kt +++ b/yorkie/src/androidTest/kotlin/dev/yorkie/core/ClientTest.kt @@ -3,8 +3,8 @@ package dev.yorkie.core import androidx.test.ext.junit.runners.AndroidJUnit4 import dev.yorkie.assertJsonContentEquals import dev.yorkie.core.Client.DocumentSyncResult -import dev.yorkie.core.Client.Event.DocumentChanged import dev.yorkie.core.Client.Event.DocumentSynced +import dev.yorkie.core.Client.Event.DocumentsChanged import dev.yorkie.core.Client.StreamConnectionStatus import dev.yorkie.document.Document import dev.yorkie.document.Document.Event.LocalChange @@ -89,8 +89,8 @@ class ClientTest { delay(50) } } - val changeEvent = assertIs( - client2Events.first { it is DocumentChanged }, + val changeEvent = assertIs( + client2Events.first { it is DocumentsChanged }, ) assertContentEquals(listOf(documentKey), changeEvent.documentKeys) var syncEvent = assertIs(client2Events.first { it is DocumentSynced }) @@ -121,15 +121,11 @@ class ClientTest { root.remove("k1") }.await() - withTimeout(GENERAL_TIMEOUT) { - while (client1Events.none { it is DocumentSynced }) { - delay(50) - } + while (client1Events.none { it is DocumentSynced }) { + delay(50) } - withTimeout(GENERAL_TIMEOUT) { - while (client2Events.isEmpty()) { - delay(50) - } + while (client2Events.isEmpty()) { + delay(50) } syncEvent = assertIs(client2Events.first { it is DocumentSynced }) assertIs(syncEvent.result) @@ -195,7 +191,7 @@ class ClientTest { root["version"] = "v2" }.await() client1.syncAsync().await() - withTimeout(GENERAL_TIMEOUT) { + withTimeout(5_000) { while (client2Events.size < 2) { delay(50) } diff --git a/yorkie/src/androidTest/kotlin/dev/yorkie/core/DocumentTest.kt b/yorkie/src/androidTest/kotlin/dev/yorkie/core/DocumentTest.kt index 74b605130..a92813cb6 100644 --- a/yorkie/src/androidTest/kotlin/dev/yorkie/core/DocumentTest.kt +++ b/yorkie/src/androidTest/kotlin/dev/yorkie/core/DocumentTest.kt @@ -147,9 +147,7 @@ class DocumentTest { assertEquals(document1.toJson(), document2.toJson()) client1.removeAsync(document1).await() - if (document2.status != DocumentStatus.Removed) { - client2.detachAsync(document2).await() - } + client2.detachAsync(document2).await() assertEquals(DocumentStatus.Removed, document1.status) assertEquals(DocumentStatus.Removed, document2.status) } diff --git a/yorkie/src/androidTest/kotlin/dev/yorkie/core/PresenceTest.kt b/yorkie/src/androidTest/kotlin/dev/yorkie/core/PresenceTest.kt index 4a097cf26..02576e2c8 100644 --- a/yorkie/src/androidTest/kotlin/dev/yorkie/core/PresenceTest.kt +++ b/yorkie/src/androidTest/kotlin/dev/yorkie/core/PresenceTest.kt @@ -8,6 +8,7 @@ import dev.yorkie.gson import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNull import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNot @@ -17,7 +18,6 @@ import kotlinx.coroutines.withTimeout import org.junit.Test import org.junit.runner.RunWith import java.util.UUID -import kotlin.test.assertIs @RunWith(AndroidJUnit4::class) class PresenceTest { @@ -83,42 +83,22 @@ class PresenceTest { @Test fun test_presence_sync() { - runBlocking { - val c1 = createClient() - val c2 = createClient() - val documentKey = UUID.randomUUID().toString().toDocKey() - val d1 = Document(documentKey) - val d2 = Document(documentKey) + withTwoClientsAndDocuments( + presences = mapOf("name" to "a") to mapOf("name" to "b"), + ) { c1, c2, d1, d2, _ -> val d1Events = mutableListOf() val d2Events = mutableListOf() - - c1.activateAsync().await() - c2.activateAsync().await() - - c1.attachAsync(d1, initialPresence = mapOf("name" to "a")).await() - val d1Job = launch(start = CoroutineStart.UNDISPATCHED) { - d1.events.filterIsInstance() - .filterNot { it is MyPresence.Initialized } - .collect(d1Events::add) - } - - c2.attachAsync(d2, initialPresence = mapOf("name" to "b")).await() - val d2Job = launch(start = CoroutineStart.UNDISPATCHED) { - d2.events.filterIsInstance() - .filterNot { it is MyPresence.Initialized } - .collect(d2Events::add) - } - - withTimeout(GENERAL_TIMEOUT) { - // watched from c2 - while (d1Events.isEmpty()) { - delay(50) - } - } - - assertEquals( - Others.Watched(PresenceInfo(c2.requireClientId(), mapOf("name" to "b"))), - d1Events.last(), + val collectJobs = listOf( + launch(start = CoroutineStart.UNDISPATCHED) { + d1.events.filterIsInstance() + .filterNot { it is MyPresence.Initialized } + .collect(d1Events::add) + }, + launch(start = CoroutineStart.UNDISPATCHED) { + d2.events.filterIsInstance() + .filterNot { it is MyPresence.Initialized } + .collect(d2Events::add) + }, ) d1.updateAsync { _, presence -> @@ -129,7 +109,7 @@ class PresenceTest { }.await() withTimeout(GENERAL_TIMEOUT) { - while (d1Events.size < 3) { + while (d1Events.size < 3 || d2Events.size < 2) { delay(50) } } @@ -139,19 +119,13 @@ class PresenceTest { MyPresence.PresenceChanged( PresenceInfo(c1.requireClientId(), mapOf("name" to "A")), ), + Others.Watched(PresenceInfo(c2.requireClientId(), mapOf("name" to "b"))), Others.PresenceChanged( PresenceInfo(c2.requireClientId(), mapOf("name" to "B")), ), ), - d1Events.takeLast(2), + d1Events, ) - - withTimeout(GENERAL_TIMEOUT) { - while (d2Events.size < 2) { - delay(50) - } - } - assertEquals( listOf( MyPresence.PresenceChanged( @@ -166,14 +140,9 @@ class PresenceTest { ), d2Events, ) - assertEquals(d1.presences.value.toMap(), d2.presences.value.toMap()) + assertEquals(d1.presences.value.entries, d2.presences.value.entries) - d1Job.cancel() - d2Job.cancel() - c1.detachAsync(d1).await() - c2.detachAsync(d2).await() - c1.deactivateAsync().await() - c2.deactivateAsync().await() + collectJobs.forEach(Job::cancel) } } @@ -213,45 +182,25 @@ class PresenceTest { val previousCursor = gson.toJson(Cursor(0, 0)) val updatedCursor = gson.toJson(Cursor(1, 1)) - runBlocking { - val c1 = createClient() - val c2 = createClient() - val documentKey = UUID.randomUUID().toString().toDocKey() - val d1 = Document(documentKey) - val d2 = Document(documentKey) + withTwoClientsAndDocuments( + presences = mapOf("name" to "a", "cursor" to previousCursor) to mapOf( + "name" to "b", + "cursor" to previousCursor, + ), + ) { c1, c2, d1, d2, _ -> val d1Events = mutableListOf() val d2Events = mutableListOf() - - c1.activateAsync().await() - c2.activateAsync().await() - - val c1ID = c1.requireClientId() - val c2ID = c2.requireClientId() - - c1.attachAsync(d1, mapOf("name" to "a", "cursor" to previousCursor)).await() - val d1Job = launch(start = CoroutineStart.UNDISPATCHED) { - d1.events.filterIsInstance() - .filterNot { it is MyPresence.Initialized } - .collect(d1Events::add) - } - - println("c2 attach") - c2.attachAsync(d2, mapOf("name" to "b", "cursor" to previousCursor)).await() - val d2Job = launch(start = CoroutineStart.UNDISPATCHED) { - d2.events.filterIsInstance() - .filterNot { it is MyPresence.Initialized } - .collect(d2Events::add) - } - - withTimeout(GENERAL_TIMEOUT + 1) { - while (d1Events.isEmpty()) { - delay(50) - } - } - - assertEquals( - Others.Watched(PresenceInfo(c2ID, d2.presences.value[c2ID]!!)), - d1Events.last(), + val collectJobs = listOf( + launch(start = CoroutineStart.UNDISPATCHED) { + d1.events.filterIsInstance() + .filterNot { it is MyPresence.Initialized } + .collect(d1Events::add) + }, + launch(start = CoroutineStart.UNDISPATCHED) { + d2.events.filterIsInstance() + .filterNot { it is MyPresence.Initialized } + .collect(d2Events::add) + }, ) d1.updateAsync { _, presence -> @@ -260,31 +209,42 @@ class PresenceTest { presence.put(mapOf("name" to "X")) }.await() - withTimeout(GENERAL_TIMEOUT + 2) { + withTimeout(GENERAL_TIMEOUT) { while (d1Events.size < 2 || d2Events.isEmpty()) { delay(50) } } assertEquals( - MyPresence.PresenceChanged( - PresenceInfo(c1ID, mapOf("name" to "X", "cursor" to updatedCursor)), + listOf( + MyPresence.PresenceChanged( + PresenceInfo( + c1.requireClientId(), + mapOf("name" to "X", "cursor" to updatedCursor), + ), + ), + Others.Watched( + PresenceInfo( + c2.requireClientId(), + mapOf("name" to "b", "cursor" to previousCursor), + ), + ), ), - d1Events.last(), + d1Events, ) assertEquals( - Others.PresenceChanged( - PresenceInfo(c1ID, mapOf("name" to "X", "cursor" to updatedCursor)), + listOf( + Others.PresenceChanged( + PresenceInfo( + c1.requireClientId(), + mapOf("name" to "X", "cursor" to updatedCursor), + ), + ), ), - d2Events.last(), + d2Events, ) - d1Job.cancel() - d2Job.cancel() - c1.detachAsync(d1).await() - c2.detachAsync(d2).await() - c1.deactivateAsync().await() - c2.deactivateAsync().await() + collectJobs.forEach(Job::cancel) } } @@ -346,275 +306,5 @@ class PresenceTest { } } - @Test - fun test_returning_online_clients_only() { - val cursor = gson.toJson(Cursor(0, 0)) - - runBlocking { - val c1 = createClient() - val c2 = createClient() - val c3 = createClient() - - val documentKey = UUID.randomUUID().toString().toDocKey() - val d1 = Document(documentKey) - val d2 = Document(documentKey) - val d3 = Document(documentKey) - val d1Events = mutableListOf() - - c1.activateAsync().await() - c2.activateAsync().await() - c3.activateAsync().await() - - val c1ID = c1.requireClientId() - val c2ID = c2.requireClientId() - val c3ID = c3.requireClientId() - - c1.attachAsync(d1, initialPresence = mapOf("name" to "a1", "cursor" to cursor)).await() - - val d1CollectJob = launch(start = CoroutineStart.UNDISPATCHED) { - d1.events.filterIsInstance() - .collect(d1Events::add) - } - - // 01. c2 attaches doc in realtime sync, and c3 attached doc in manual sync. - c2.attachAsync(d2, initialPresence = mapOf("name" to "b1", "cursor" to cursor)).await() - c3.attachAsync(d3, mapOf("name" to "c1", "cursor" to cursor), false).await() - - withTimeout(GENERAL_TIMEOUT) { - // c2 watched - while (d1Events.isEmpty()) { - delay(50) - } - } - - assertIs(d1Events.last()) - assertEquals( - mapOf(c1ID to d1.presences.value[c1ID], c2ID to d2.presences.value[c2ID]), - d1.presences.value.toMap(), - ) - assertNull(d1.presences.value[c3ID]) - - // 02. c2 pauses the document (in manual sync), c3 resumes the document (in realtime sync). - c2.pause(d2) - - withTimeout(GENERAL_TIMEOUT) { - // c2 unwatched - while (d1Events.size < 2) { - delay(50) - } - } - - assertIs(d1Events.last()) - c3.resume(d3) - - withTimeout(GENERAL_TIMEOUT) { - // c3 watched - while (d1Events.size < 3) { - delay(50) - } - } - - assertIs(d1Events.last()) - assertEquals( - mapOf(c1ID to d1.presences.value[c1ID], c3ID to d3.presences.value[c3ID]), - d1.presences.value.toMap(), - ) - assertNull(d1.presences.value[c2ID]) - - d1CollectJob.cancel() - c1.detachAsync(d1).await() - c3.detachAsync(d3).await() - c1.deactivateAsync().await() - c2.deactivateAsync().await() - c3.deactivateAsync().await() - } - } - - @Test - fun test_receiving_presence_events_only_for_realtime_sync() { - val cursor = gson.toJson(Cursor(0, 0)) - runBlocking { - val c1 = createClient() - val c2 = createClient() - val c3 = createClient() - - val documentKey = UUID.randomUUID().toString().toDocKey() - val d1 = Document(documentKey) - val d2 = Document(documentKey) - val d3 = Document(documentKey) - val d1Events = mutableListOf() - - c1.activateAsync().await() - c2.activateAsync().await() - c3.activateAsync().await() - - val c2ID = c2.requireClientId() - val c3ID = c3.requireClientId() - - c1.attachAsync(d1, initialPresence = mapOf("name" to "a1", "cursor" to cursor)) - .await() - - val d1CollectJob = launch(start = CoroutineStart.UNDISPATCHED) { - d1.events.filterIsInstance() - .collect(d1Events::add) - } - - // 01. c2 attaches doc in realtime sync, and c3 attached doc in manual sync. - // c1 receives the watched event from c2. - c2.attachAsync(d2, initialPresence = mapOf("name" to "b1", "cursor" to cursor)) - .await() - c3.attachAsync(d3, mapOf("name" to "c1", "cursor" to cursor), false).await() - - withTimeout(GENERAL_TIMEOUT) { - // c2 watched - while (d1Events.isEmpty()) { - delay(50) - } - } - - assertEquals(1, d1Events.size) - assertEquals( - Others.Watched(PresenceInfo(c2ID, mapOf("name" to "b1", "cursor" to cursor))), - d1Events.last(), - ) - - // 02. c2 and c3 update the presence. - // c1 receives the presence-changed event from c2. - d2.updateAsync { _, presence -> - presence.put(mapOf("name" to "b2")) - }.await() - - d3.updateAsync { _, presence -> - presence.put(mapOf("name" to "c2")) - }.await() - - withTimeout(GENERAL_TIMEOUT) { - // c2 presence changed - while (d1Events.size < 2) { - delay(50) - } - } - - assertEquals(2, d1Events.size) - assertEquals( - Others.PresenceChanged( - PresenceInfo( - c2ID, - mapOf("name" to "b2", "cursor" to cursor), - ), - ), - d1Events.last(), - ) - - // 03-1. c2 pauses the document, c1 receives an unwatched event from c2. - c2.pause(d2) - - withTimeout(GENERAL_TIMEOUT) { - // c2 unwatched - while (d1Events.size < 3) { - delay(50) - } - } - - assertEquals(3, d1Events.size) - assertEquals( - Others.Unwatched(PresenceInfo(c2ID, mapOf("name" to "b2", "cursor" to cursor))), - d1Events.last(), - ) - - // 03-2. c3 resumes the document, c1 receives a watched event from c3. - // NOTE(chacha912): The events are influenced by the timing of realtime sync - // and watch stream resolution. For deterministic testing, the resume is performed - // after the sync. Since the sync updates c1 with all previous presence changes - // from c3, only the watched event is triggered. - c3.syncAsync().await() - c1.syncAsync().await() - c3.resume(d3) - - withTimeout(GENERAL_TIMEOUT) { - // c3 watched - while (d1Events.size < 4) { - delay(50) - } - } - - assertEquals(4, d1Events.size) - assertEquals( - Others.Watched(PresenceInfo(c3ID, mapOf("name" to "c2", "cursor" to cursor))), - d1Events.last(), - ) - - // 04. c2 and c3 update the presence. - // c1 receives the presence-changed event from c3. - d2.updateAsync { _, presence -> - presence.put(mapOf("name" to "b3")) - }.await() - - d3.updateAsync { _, presence -> - presence.put(mapOf("name" to "c3")) - }.await() - - withTimeout(GENERAL_TIMEOUT) { - // c3 presence changed - while (d1Events.size < 5) { - delay(50) - } - } - - assertEquals(5, d1Events.size) - assertEquals( - Others.PresenceChanged( - PresenceInfo( - c3ID, - mapOf("name" to "c3", "cursor" to cursor), - ), - ), - d1Events.last(), - ) - - // 05-1. c3 pauses the document, c1 receives an unwatched event from c3. - c3.pause(d3) - - withTimeout(GENERAL_TIMEOUT) { - // c3 unwatched - while (d1Events.size < 6) { - delay(50) - } - } - - assertEquals(6, d1Events.size) - assertEquals( - Others.Unwatched(PresenceInfo(c3ID, mapOf("name" to "c3", "cursor" to cursor))), - d1Events.last(), - ) - - // 05-2. c2 resumes the document, c1 receives a watched event from c2. - c2.syncAsync().await() - c1.syncAsync().await() - c2.resume(d2) - - withTimeout(GENERAL_TIMEOUT) { - // c3 unwatched - while (d1Events.size < 7) { - delay(50) - } - } - - assertEquals(7, d1Events.size) - assertEquals( - Others.Watched(PresenceInfo(c2ID, mapOf("name" to "b3", "cursor" to cursor))), - d1Events.last(), - ) - - d1CollectJob.cancel() - c1.detachAsync(d1).await() - c2.detachAsync(d2).await() - c3.detachAsync(d3).await() - c1.deactivateAsync().await() - c2.deactivateAsync().await() - c3.deactivateAsync().await() - } - } - private data class Cursor(val x: Int, val y: Int) } diff --git a/yorkie/src/androidTest/kotlin/dev/yorkie/document/json/JsonTreeTest.kt b/yorkie/src/androidTest/kotlin/dev/yorkie/document/json/JsonTreeTest.kt index f6cb811aa..d97c7a272 100644 --- a/yorkie/src/androidTest/kotlin/dev/yorkie/document/json/JsonTreeTest.kt +++ b/yorkie/src/androidTest/kotlin/dev/yorkie/document/json/JsonTreeTest.kt @@ -1,7 +1,6 @@ package dev.yorkie.document.json import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.LargeTest import dev.yorkie.core.Client import dev.yorkie.core.Presence import dev.yorkie.core.withTwoClientsAndDocuments @@ -13,7 +12,6 @@ import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertEquals -@LargeTest @RunWith(AndroidJUnit4::class) class JsonTreeTest { @@ -69,6 +67,8 @@ class JsonTreeTest { ) assertTreesXmlEquals("

12

", d1, d2) + // TODO: add inserting on the leftmost after tree is fixed + updateAndSync( Updater(c1, d1) { root, _ -> root.rootTree().edit(2, 2, text { "A" }) @@ -118,7 +118,7 @@ class JsonTreeTest { updateAndSync( Updater(c1, d1) { root, _ -> - root.rootTree().style(0, 1, mapOf("bold" to "true")) + root.rootTree().style(6, 7, mapOf("bold" to "true")) }, Updater(c2, d2), ) @@ -130,1333 +130,6 @@ class JsonTreeTest { } } - @Test - fun test_deleting_overlapping_elements_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") - element("i") - element("b") - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(0, 4) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(2, 6) - }.await() - assertEquals("", d1.getRoot().rootTree().toXml()) - assertEquals("

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("", d1, d2) - } - } - - @Test - fun test_deleting_overlapping_text_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "abcd" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

abcd

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(1, 4) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(2, 5) - }.await() - assertEquals("

d

", d1.getRoot().rootTree().toXml()) - assertEquals("

a

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

", d1, d2) - } - } - - @Test - fun test_insert_and_delete_contained_elements_of_the_same_depth_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "1234" } - } - element("p") { - text { "abcd" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

1234

abcd

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(6, 6, element("p")) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(0, 12) - }.await() - assertEquals("

1234

abcd

", d1.getRoot().rootTree().toXml()) - assertEquals("", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

", d1, d2) - } - } - - @Test - fun test_multiple_insert_and_delete_contained_elements_of_the_same_depth_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "1234" } - } - element("p") { - text { "abcd" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

1234

abcd

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(6, 6, element("p")) - root.rootTree().edit(8, 8, element("p")) - root.rootTree().edit(10, 10, element("p")) - root.rootTree().edit(12, 12, element("p")) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(0, 12) - }.await() - assertEquals( - "

1234

abcd

", - d1.getRoot().rootTree().toXml(), - ) - assertEquals("", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

", d1, d2) - } - } - - @Test - fun test_detecting_error_when_inserting_and_deleting_contained_elements_at_different_depths() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - element("i") - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(2, 2, element("i")) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(1, 3) - }.await() - assertEquals("

", d1.getRoot().rootTree().toXml()) - assertEquals("

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

", d1, d2) - } - } - - @Test - fun test_deleting_contained_elements_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - element("i") { - text { "1234" } - } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

1234

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(0, 8) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(1, 7) - }.await() - assertEquals("", d1.getRoot().rootTree().toXml()) - assertEquals("

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("", d1, d2) - } - } - - @Test - fun test_insert_and_delete_contained_text_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "1234" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

1234

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(1, 5) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(3, 3, text { "a" }) - }.await() - assertEquals("

", d1.getRoot().rootTree().toXml()) - assertEquals("

12a34

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

a

", d1, d2) - } - } - - @Test - fun test_deleting_contained_text_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "1234" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

1234

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(1, 5) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(2, 4) - }.await() - assertEquals("

", d1.getRoot().rootTree().toXml()) - assertEquals("

14

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

", d1, d2) - } - } - - @Test - fun test_insert_and_delete_contained_text_and_elements_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "1234" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

1234

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(0, 6) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(3, 3, text { "a" }) - }.await() - assertEquals("", d1.getRoot().rootTree().toXml()) - assertEquals("

12a34

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("", d1, d2) - } - } - - @Test - fun test_delete_contained_text_and_elements_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "1234" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

1234

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(0, 6) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(1, 5) - }.await() - assertEquals("", d1.getRoot().rootTree().toXml()) - assertEquals("

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("", d1, d2) - } - } - - @Test - fun test_insert_side_by_side_elements_into_left_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(0, 0, element("b")) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(0, 0, element("i")) - }.await() - assertEquals("

", d1.getRoot().rootTree().toXml()) - assertEquals("

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

", d1, d2) - } - } - - @Test - fun test_insert_side_by_side_elements_into_middle_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(1, 1, element("b")) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(1, 1, element("i")) - }.await() - assertEquals("

", d1.getRoot().rootTree().toXml()) - assertEquals("

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

", d1, d2) - } - } - - @Test - fun test_insert_side_by_side_elements_into_right_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(2, 2, element("b")) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(2, 2, element("i")) - }.await() - assertEquals("

", d1.getRoot().rootTree().toXml()) - assertEquals("

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

", d1, d2) - } - } - - @Test - fun test_insert_and_delete_side_by_side_elements_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - element("b") - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(1, 3) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(1, 1, element("i")) - }.await() - assertEquals("

", d1.getRoot().rootTree().toXml()) - assertEquals("

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

", d1, d2) - } - } - - @Test - fun test_delete_and_insert_side_by_side_elements_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - element("b") - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(1, 3) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(3, 3, element("i")) - }.await() - assertEquals("

", d1.getRoot().rootTree().toXml()) - assertEquals("

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

", d1, d2) - } - } - - @Test - fun test_deleting_side_by_side_elements_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - element("b") - element("i") - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(1, 3) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(3, 5) - }.await() - assertEquals("

", d1.getRoot().rootTree().toXml()) - assertEquals("

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

", d1, d2) - } - } - - @Test - fun test_insert_text_to_the_same_left_position_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "12" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

12

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(1, 1, text { "A" }) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(1, 1, text { "B" }) - }.await() - assertEquals("

A12

", d1.getRoot().rootTree().toXml()) - assertEquals("

B12

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

BA12

", d1, d2) - } - } - - @Test - fun test_insert_text_to_the_same_middle_position_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "12" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

12

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(2, 2, text { "A" }) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(2, 2, text { "B" }) - }.await() - assertEquals("

1A2

", d1.getRoot().rootTree().toXml()) - assertEquals("

1B2

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

1BA2

", d1, d2) - } - } - - @Test - fun test_insert_text_to_the_same_right_position_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "12" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

12

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(3, 3, text { "A" }) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(3, 3, text { "B" }) - }.await() - assertEquals("

12A

", d1.getRoot().rootTree().toXml()) - assertEquals("

12B

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

12BA

", d1, d2) - } - } - - @Test - fun test_insert_and_delete_side_by_side_text_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "1234" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

1234

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(3, 3, text { "a" }) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(3, 5) - }.await() - assertEquals("

12a34

", d1.getRoot().rootTree().toXml()) - assertEquals("

12

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

12a

", d1, d2) - } - } - - @Test - fun test_delete_and_insert_side_by_side_text_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "1234" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

1234

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(3, 3, text { "a" }) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(1, 3) - }.await() - assertEquals("

12a34

", d1.getRoot().rootTree().toXml()) - assertEquals("

34

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

a34

", d1, d2) - } - } - - @Test - fun test_delete_side_by_side_text_blocks_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "1234" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

1234

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(3, 5) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(1, 3) - }.await() - assertEquals("

12

", d1.getRoot().rootTree().toXml()) - assertEquals("

34

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

", d1, d2) - } - } - - @Test - fun test_delete_text_content_at_the_same_left_position_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "123" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

123

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(1, 2) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(1, 2) - }.await() - assertEquals("

23

", d1.getRoot().rootTree().toXml()) - assertEquals("

23

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

23

", d1, d2) - } - } - - @Test - fun test_delete_text_content_at_the_same_middle_position_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "123" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

123

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(2, 3) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(2, 3) - }.await() - assertEquals("

13

", d1.getRoot().rootTree().toXml()) - assertEquals("

13

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

13

", d1, d2) - } - } - - @Test - fun test_delete_text_content_at_the_same_right_position_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "123" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

123

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(3, 4) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(3, 4) - }.await() - assertEquals("

12

", d1.getRoot().rootTree().toXml()) - assertEquals("

12

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

12

", d1, d2) - } - } - - @Test - fun test_delete_text_content_anchored_to_another_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "123" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

123

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(1, 2) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(2, 3) - }.await() - assertEquals("

23

", d1.getRoot().rootTree().toXml()) - assertEquals("

13

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

3

", d1, d2) - } - } - - @Test - fun test_producing_complete_deletion_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "123" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

123

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(1, 2) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(2, 4) - }.await() - assertEquals("

23

", d1.getRoot().rootTree().toXml()) - assertEquals("

1

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

", d1, d2) - } - } - - @Test - fun test_handling_block_delete_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "12345" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

12345

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(1, 3) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(4, 6) - }.await() - assertEquals("

345

", d1.getRoot().rootTree().toXml()) - assertEquals("

123

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

3

", d1, d2) - } - } - - @Test - fun test_handling_insertion_within_block_delete_concurrently_case1() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "12345" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

12345

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(2, 5) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(3, 3, text { "B" }) - }.await() - assertEquals("

15

", d1.getRoot().rootTree().toXml()) - assertEquals("

12B345

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

1B5

", d1, d2) - } - } - - @Test - fun test_handling_insertion_within_block_delete_concurrently_case2() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "12345" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

12345

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(2, 6) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(3, 3, text { "a" }, text { "bc" }) - }.await() - assertEquals("

1

", d1.getRoot().rootTree().toXml()) - assertEquals("

12abc345

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

1abc

", d1, d2) - } - } - - @Test - fun test_handling_block_element_insertion_within_deletion() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "1234" } - } - element("p") { - text { "5678" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

1234

5678

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(0, 12) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit( - 6, - 6, - element("p") { text { "cd" } }, - element("i") { text { "fg" } }, - ) - }.await() - assertEquals("", d1.getRoot().rootTree().toXml()) - assertEquals( - "

1234

cd

fg

5678

", - d2.getRoot().rootTree().toXml(), - ) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

cd

fg
", d1, d2) - } - } - - @Test - fun test_handling_concurrent_element_insertion_and_deletion_to_left() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "12345" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

12345

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(0, 7) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit( - 0, - 0, - element("p") { text { "cd" } }, - element("i") { text { "fg" } }, - ) - }.await() - assertEquals("", d1.getRoot().rootTree().toXml()) - assertEquals( - "

cd

fg

12345

", - d2.getRoot().rootTree().toXml(), - ) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

cd

fg
", d1, d2) - } - } - - @Test - fun test_handling_concurrent_element_insertion_and_deletion_to_right() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "12345" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

12345

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(0, 7) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit( - 7, - 7, - element("p") { text { "cd" } }, - element("i") { text { "fg" } }, - ) - }.await() - assertEquals("", d1.getRoot().rootTree().toXml()) - assertEquals( - "

12345

cd

fg
", - d2.getRoot().rootTree().toXml(), - ) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

cd

fg
", d1, d2) - } - } - - @Test - fun test_handling_deletion_of_insertion_anchor_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "12" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

12

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(2, 2, text { "A" }) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(1, 2) - }.await() - assertEquals("

1A2

", d1.getRoot().rootTree().toXml()) - assertEquals("

2

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

A2

", d1, d2) - } - } - - @Test - fun test_handling_deletion_after_insertion_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "12" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

12

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(1, 1, text { "A" }) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(1, 3) - }.await() - assertEquals("

A12

", d1.getRoot().rootTree().toXml()) - assertEquals("

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

A

", d1, d2) - } - } - - @Test - fun test_handling_deletion_before_insertion_concurrently() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("r") { - element("p") { - text { "12" } - } - }, - ) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

12

", d1, d2) - - d1.updateAsync { root, _ -> - root.rootTree().edit(3, 3, text { "A" }) - }.await() - d2.updateAsync { root, _ -> - root.rootTree().edit(1, 3) - }.await() - assertEquals("

12A

", d1.getRoot().rootTree().toXml()) - assertEquals("

", d2.getRoot().rootTree().toXml()) - - c1.syncAsync().await() - c2.syncAsync().await() - c1.syncAsync().await() - assertTreesXmlEquals("

A

", d1, d2) - } - } - - @Test - fun test_whether_split_link_can_be_transmitted_through_rpc() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - updateAndSync( - Updater(c1, d1) { root, _ -> - root.setNewTree( - "t", - element("doc") { - element("p") { - text { "ab" } - } - }, - ).edit(2, 2, text { "1" }) - }, - Updater(c2, d2), - ) - assertTreesXmlEquals("

a1b

", d1, d2) - - d2.updateAsync { root, _ -> - root.rootTree().edit(3, 3, text { "1" }) - }.await() - assertEquals("

a11b

", d2.getRoot().rootTree().toXml()) - - d2.updateAsync { root, _ -> - root.rootTree().apply { - edit(2, 3, text { "12" }) - edit(4, 5, text { "21" }) - } - }.await() - assertEquals("

a1221b

", d2.getRoot().rootTree().toXml()) - - // if split link is not transmitted, then left sibling in from index below, is "b" not "a" - d2.updateAsync { root, _ -> - root.rootTree().edit(2, 4, text { "123" }) - }.await() - assertEquals("

a12321b

", d2.getRoot().rootTree().toXml()) - } - } - - @Test - fun test_calculating_size_of_index_tree() { - withTwoClientsAndDocuments(realTimeSync = false) { c1, c2, d1, d2, _ -> - d1.updateAsync { root, _ -> - root.setNewTree( - "t", - element("doc") { - element("p") { - text { "ab" } - } - }, - ).apply { - edit(2, 2, text { "123" }) - edit(2, 2, text { "456" }) - edit(2, 2, text { "789" }) - edit(2, 2, text { "0123" }) - } - }.await() - - assertEquals("

a0123789456123b

", d1.getRoot().rootTree().toXml()) - c1.syncAsync().await() - c2.syncAsync().await() - - val size = d1.getRoot().rootTree().indexTree.root.size - assertEquals(size, d2.getRoot().rootTree().indexTree.root.size) - } - } - companion object { fun JsonObject.rootTree() = getAs("t") diff --git a/yorkie/src/main/kotlin/dev/yorkie/api/ElementConverter.kt b/yorkie/src/main/kotlin/dev/yorkie/api/ElementConverter.kt index 4439e0d05..1cc546047 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/api/ElementConverter.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/api/ElementConverter.kt @@ -19,7 +19,6 @@ import dev.yorkie.api.v1.textNode import dev.yorkie.api.v1.textNodeID import dev.yorkie.api.v1.textNodePos import dev.yorkie.api.v1.treeNode -import dev.yorkie.api.v1.treeNodeID import dev.yorkie.api.v1.treeNodes import dev.yorkie.api.v1.treePos import dev.yorkie.core.P @@ -35,7 +34,6 @@ import dev.yorkie.document.crdt.CrdtTree import dev.yorkie.document.crdt.CrdtTreeNode import dev.yorkie.document.crdt.CrdtTreeNode.Companion.CrdtTreeElement import dev.yorkie.document.crdt.CrdtTreeNode.Companion.CrdtTreeText -import dev.yorkie.document.crdt.CrdtTreeNodeID import dev.yorkie.document.crdt.CrdtTreePos import dev.yorkie.document.crdt.ElementRht import dev.yorkie.document.crdt.RgaTreeList @@ -66,7 +64,6 @@ internal typealias PBTextNode = dev.yorkie.api.v1.TextNode internal typealias PBTree = dev.yorkie.api.v1.JSONElement.Tree internal typealias PBTreeNode = dev.yorkie.api.v1.TreeNode internal typealias PBTreePos = dev.yorkie.api.v1.TreePos -internal typealias PBTreeNodeID = dev.yorkie.api.v1.TreeNodeID internal typealias PBTreeNodes = dev.yorkie.api.v1.TreeNodes internal typealias PBSnapshot = dev.yorkie.api.v1.Snapshot @@ -228,13 +225,12 @@ internal fun List.toCrdtTreeRootNode(): CrdtTreeNode? { } internal fun PBTreeNode.toCrdtTreeNode(): CrdtTreeNode { - val id = id.toCrdtTreeNodeID() - val convertedRemovedAt = removedAtOrNull?.toTimeTicket() + val pos = pos.toCrdtTreePos() return if (type == IndexTreeNode.DEFAULT_TEXT_TYPE) { - CrdtTreeText(id, value) + CrdtTreeText(pos, value) } else { CrdtTreeElement( - id, + pos, type, attributes = Rht().also { attributesMap.forEach { (key, value) -> @@ -242,24 +238,10 @@ internal fun PBTreeNode.toCrdtTreeNode(): CrdtTreeNode { } }, ) - }.apply { - convertedRemovedAt?.let(::remove) - if (hasInsPrevId()) { - insPrevID = insPrevId.toCrdtTreeNodeID() - } - if (hasInsNextId()) { - insNextID = insNextId.toCrdtTreeNodeID() - } } } -internal fun PBTreePos.toCrdtTreePos(): CrdtTreePos { - return CrdtTreePos(parentId.toCrdtTreeNodeID(), leftSiblingId.toCrdtTreeNodeID()) -} - -internal fun PBTreeNodeID.toCrdtTreeNodeID(): CrdtTreeNodeID { - return CrdtTreeNodeID(createdAt.toTimeTicket(), offset) -} +internal fun PBTreePos.toCrdtTreePos(): CrdtTreePos = CrdtTreePos(createdAt.toTimeTicket(), offset) internal fun CrdtElement.toPBJsonElement(): PBJsonElement { return when (this) { @@ -313,21 +295,14 @@ internal fun RgaTreeList.toPBRgaNodes(): List { } internal fun CrdtTreeNode.toPBTreeNodes(): List { - val treeNode = this return buildList { - traverse(treeNode) { node, nodeDepth -> + traverse(this@toPBTreeNodes) { node, nodeDepth -> val pbTreeNode = treeNode { - id = node.id.toPBTreeNodeID() + pos = node.pos.toPBTreePos() type = node.type if (node.isText) { value = node.value } - node.insPrevID?.let { - insPrevId = it.toPBTreeNodeID() - } - node.insNextID?.let { - insNextId = it.toPBTreeNodeID() - } node.removedAt?.toPBTimeTicket()?.let { removedAt = it } @@ -361,16 +336,8 @@ internal fun List.toCrdtTreeNodesWhenEdit(): List? { internal fun CrdtTreePos.toPBTreePos(): PBTreePos { val crdtTreePos = this return treePos { - parentId = crdtTreePos.parentID.toPBTreeNodeID() - leftSiblingId = crdtTreePos.leftSiblingID.toPBTreeNodeID() - } -} - -internal fun CrdtTreeNodeID.toPBTreeNodeID(): PBTreeNodeID { - val nodeID = this - return treeNodeID { - createdAt = nodeID.createdAt.toPBTimeTicket() - offset = nodeID.offset + createdAt = crdtTreePos.createdAt.toPBTimeTicket() + offset = crdtTreePos.offset } } diff --git a/yorkie/src/main/kotlin/dev/yorkie/api/OperationConverter.kt b/yorkie/src/main/kotlin/dev/yorkie/api/OperationConverter.kt index 4907d73b4..aba0f2d40 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/api/OperationConverter.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/api/OperationConverter.kt @@ -5,6 +5,7 @@ import dev.yorkie.api.v1.OperationKt.edit import dev.yorkie.api.v1.OperationKt.increase import dev.yorkie.api.v1.OperationKt.move import dev.yorkie.api.v1.OperationKt.remove +import dev.yorkie.api.v1.OperationKt.select import dev.yorkie.api.v1.OperationKt.set import dev.yorkie.api.v1.OperationKt.style import dev.yorkie.api.v1.OperationKt.treeEdit @@ -16,6 +17,7 @@ import dev.yorkie.document.operation.IncreaseOperation import dev.yorkie.document.operation.MoveOperation import dev.yorkie.document.operation.Operation import dev.yorkie.document.operation.RemoveOperation +import dev.yorkie.document.operation.SelectOperation import dev.yorkie.document.operation.SetOperation import dev.yorkie.document.operation.StyleOperation import dev.yorkie.document.operation.TreeEditOperation @@ -25,7 +27,7 @@ import dev.yorkie.document.time.ActorID internal typealias PBOperation = dev.yorkie.api.v1.Operation internal fun List.toOperations(): List { - return mapNotNull { + return map { when { it.hasSet() -> SetOperation( key = it.set.key, @@ -75,7 +77,12 @@ internal fun List.toOperations(): List { ?: mapOf(), ) - it.hasSelect() -> null + it.hasSelect() -> SelectOperation( + fromPos = it.select.from.toRgaTreeSplitNodePos(), + toPos = it.select.to.toRgaTreeSplitNodePos(), + parentCreatedAt = it.select.parentCreatedAt.toTimeTicket(), + executedAt = it.select.executedAt.toTimeTicket(), + ) it.hasStyle() -> StyleOperation( fromPos = it.style.from.toRgaTreeSplitNodePos(), @@ -91,10 +98,6 @@ internal fun List.toOperations(): List { toPos = it.treeEdit.to.toCrdtTreePos(), contents = it.treeEdit.contentsList.toCrdtTreeNodesWhenEdit(), executedAt = it.treeEdit.executedAt.toTimeTicket(), - maxCreatedAtMapByActor = - it.treeEdit.createdAtMapByActorMap.entries.associate { (key, value) -> - ActorID(key) to value.toTimeTicket() - }, ) it.hasTreeStyle() -> TreeStyleOperation( @@ -181,6 +184,17 @@ internal fun Operation.toPBOperation(): PBOperation { } } + is SelectOperation -> { + operation { + select = select { + parentCreatedAt = operation.parentCreatedAt.toPBTimeTicket() + from = operation.fromPos.toPBTextNodePos() + to = operation.toPos.toPBTextNodePos() + executedAt = operation.executedAt.toPBTimeTicket() + } + } + } + is StyleOperation -> { operation { style = style { @@ -201,11 +215,6 @@ internal fun Operation.toPBOperation(): PBOperation { to = operation.toPos.toPBTreePos() executedAt = operation.executedAt.toPBTimeTicket() contents.addAll(operation.contents?.toPBTreeNodesWhenEdit().orEmpty()) - createdAtMapByActor.putAll( - operation.maxCreatedAtMapByActor.entries.associate { - it.key.value to it.value.toPBTimeTicket() - }, - ) } } } diff --git a/yorkie/src/main/kotlin/dev/yorkie/core/Client.kt b/yorkie/src/main/kotlin/dev/yorkie/core/Client.kt index e3c66f07c..c45b07ede 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/core/Client.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/core/Client.kt @@ -2,6 +2,8 @@ package dev.yorkie.core import android.content.Context import com.google.common.annotations.VisibleForTesting +import dev.yorkie.api.toActorID +import dev.yorkie.api.toByteString import dev.yorkie.api.toChangePack import dev.yorkie.api.toPBChangePack import dev.yorkie.api.v1.DocEventType @@ -17,7 +19,6 @@ import dev.yorkie.api.v1.watchDocumentRequest import dev.yorkie.core.Client.DocumentSyncResult.SyncFailed import dev.yorkie.core.Client.DocumentSyncResult.Synced import dev.yorkie.core.Client.Event.DocumentSynced -import dev.yorkie.core.Presences.Companion.UninitializedPresences import dev.yorkie.core.Presences.Companion.asPresences import dev.yorkie.document.Document import dev.yorkie.document.Document.DocumentStatus @@ -43,7 +44,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.fold import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.retry @@ -133,7 +133,12 @@ public class Client @VisibleForTesting internal constructor( YorkieLogger.e("Client.activate", e.stackTraceToString()) return@async false } - _status.emit(Status.Activated(ActorID(activateResponse.clientId))) + _status.emit( + Status.Activated( + activateResponse.clientId.toActorID(), + activateResponse.clientKey, + ), + ) runSyncLoop() runWatchLoop() true @@ -203,7 +208,7 @@ public class Client @VisibleForTesting internal constructor( document, runCatching { val request = pushPullChangesRequest { - clientId = requireClientId().value + clientId = requireClientId().toByteString() changePack = document.createChangePack().toPBChangePack() documentId = documentID pushOnly = syncMode == SyncMode.PushOnly @@ -255,7 +260,7 @@ public class Client @VisibleForTesting internal constructor( return scope.launch(activationJob) { service.watchDocument( watchDocumentRequest { - clientId = requireClientId().value + clientId = requireClientId().toByteString() documentId = attachment.documentID }, documentBasedRequestHeader(attachment.document.key), @@ -275,12 +280,14 @@ public class Client @VisibleForTesting internal constructor( response: WatchDocumentResponse, ) { if (response.hasInitialization()) { - val document = attachments.value[documentKey]?.document ?: return - val clientIDs = response.initialization.clientIdsList.map { ActorID(it) } - document.onlineClients.value = document.onlineClients.value + clientIDs - document.publish( - PresenceChange.MyPresence.Initialized(document.presences.value.asPresences()), - ) + response.initialization.clientIdsList.forEach { pbClientID -> + val clientID = pbClientID.toActorID() + val document = attachments.value[documentKey]?.document ?: return + document.onlineClients.add(clientID) + document.publish( + PresenceChange.MyPresence.Initialized(document.presences.value.asPresences()), + ) + } return } @@ -288,36 +295,32 @@ public class Client @VisibleForTesting internal constructor( val eventType = checkNotNull(watchEvent.type) // only single key will be received since 0.3.1 server. val attachment = attachments.value[documentKey] ?: return - val document = attachment.document - val publisher = ActorID(watchEvent.publisher) + val publisher = watchEvent.publisher.toActorID() when (eventType) { - DocEventType.DOC_EVENT_TYPE_DOCUMENT_WATCHED -> { - // NOTE(chacha912): We added to onlineClients, but we won't trigger watched event - // unless we also know their initial presence data at this point. - document.onlineClients.value = document.onlineClients.value + publisher - if (publisher in document.allPresences.value) { - val presence = document.presences.first { publisher in it }[publisher] ?: return - document.publish( + DocEventType.DOC_EVENT_TYPE_DOCUMENTS_WATCHED -> { + attachment.document.onlineClients.add(publisher) + if (publisher in attachment.document.presences.value) { + val presence = attachment.document.presences.value[publisher] ?: return + attachment.document.publish( PresenceChange.Others.Watched(PresenceInfo(publisher, presence)), ) } } - DocEventType.DOC_EVENT_TYPE_DOCUMENT_UNWATCHED -> { - // NOTE(chacha912): There is no presence, - // when PresenceChange(clear) is applied before unwatching. In that case, - // the 'unwatched' event is triggered while handling the PresenceChange. - val presence = document.presences.value[publisher] ?: return - document.onlineClients.value = document.onlineClients.value - publisher - document.publish(PresenceChange.Others.Unwatched(PresenceInfo(publisher, presence))) + DocEventType.DOC_EVENT_TYPE_DOCUMENTS_UNWATCHED -> { + attachment.document.onlineClients.remove(publisher) + val presence = attachment.document.presences.value[publisher] ?: return + attachment.document.publish( + PresenceChange.Others.Unwatched(PresenceInfo(publisher, presence)), + ) } - DocEventType.DOC_EVENT_TYPE_DOCUMENT_CHANGED -> { + DocEventType.DOC_EVENT_TYPE_DOCUMENTS_CHANGED -> { attachments.value += documentKey to attachment.copy( remoteChangeEventReceived = true, ) - eventStream.emit(Event.DocumentChanged(listOf(documentKey))) + eventStream.emit(Event.DocumentsChanged(listOf(documentKey))) } DocEventType.UNRECOGNIZED -> { @@ -348,7 +351,7 @@ public class Client @VisibleForTesting internal constructor( }.await() val request = attachDocumentRequest { - clientId = requireClientId().value + clientId = requireClientId().toByteString() changePack = document.createChangePack().toPBChangePack() } val response = try { @@ -373,7 +376,6 @@ public class Client @VisibleForTesting internal constructor( response.documentId, isRealTimeSync, ) - waitForInitialization(document.key) true } } @@ -399,7 +401,7 @@ public class Client @VisibleForTesting internal constructor( }.await() val request = detachDocumentRequest { - clientId = requireClientId().value + clientId = requireClientId().toByteString() changePack = document.createChangePack().toPBChangePack() documentId = attachment.documentID } @@ -436,7 +438,7 @@ public class Client @VisibleForTesting internal constructor( try { service.deactivateClient( deactivateClientRequest { - clientId = requireClientId().value + clientId = requireClientId().toByteString() }, projectBasedRequestHeader, ) @@ -461,7 +463,7 @@ public class Client @VisibleForTesting internal constructor( ?: throw IllegalArgumentException("document is not attached") val request = removeDocumentRequest { - clientId = requireClientId().value + clientId = requireClientId().toByteString() changePack = document.createChangePack(forceRemove = true).toPBChangePack() documentId = attachment.documentID } @@ -481,13 +483,6 @@ public class Client @VisibleForTesting internal constructor( } } - private suspend fun waitForInitialization(documentKey: Document.Key) { - val attachment = attachments.value[documentKey] ?: return - if (attachment.isRealTimeSync) { - attachment.document.presences.first { it != UninitializedPresences } - } - } - public fun requireClientId() = (status.value as Status.Activated).clientId /** @@ -557,7 +552,10 @@ public class Client @VisibleForTesting internal constructor( * Means that the client is activated. If the client is activated, * all [Document]s of the client are ready to be used. */ - public class Activated internal constructor(public val clientId: ActorID) : Status + public class Activated internal constructor( + public val clientId: ActorID, + public val clientKey: String, + ) : Status /** * Means that the client is not activated. It is the initial stastus of the client. @@ -639,7 +637,7 @@ public class Client @VisibleForTesting internal constructor( /** * Means that the documents of the client has changed. */ - public class DocumentChanged(public val documentKeys: List) : Event + public class DocumentsChanged(public val documentKeys: List) : Event /** * Means that the document has synced with the server. diff --git a/yorkie/src/main/kotlin/dev/yorkie/core/Presence.kt b/yorkie/src/main/kotlin/dev/yorkie/core/Presence.kt index 4cdec0fae..a00659b9a 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/core/Presence.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/core/Presence.kt @@ -13,7 +13,7 @@ public data class Presence internal constructor( public fun put(data: Map) { presence.putAll(data) - changeContext.presenceChange = PresenceChange.Put(presence) + changeContext.presenceChange = PresenceChange.Put(presence + data) } public fun clear() { diff --git a/yorkie/src/main/kotlin/dev/yorkie/core/Presences.kt b/yorkie/src/main/kotlin/dev/yorkie/core/Presences.kt index ec156a386..c4d082123 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/core/Presences.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/core/Presences.kt @@ -30,13 +30,6 @@ public class Presences private constructor( public fun Pair.asPresences(): Presences { return Presences(mutableMapOf(first to second.toMutableMap())) } - - internal val UninitializedPresences = Presences( - object : HashMap>() { - - override fun equals(other: Any?): Boolean = this === other - }, - ) } } diff --git a/yorkie/src/main/kotlin/dev/yorkie/document/Document.kt b/yorkie/src/main/kotlin/dev/yorkie/document/Document.kt index 6b4399439..b95393f70 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/document/Document.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/document/Document.kt @@ -8,7 +8,6 @@ import dev.yorkie.core.Presence import dev.yorkie.core.PresenceChange import dev.yorkie.core.PresenceInfo import dev.yorkie.core.Presences -import dev.yorkie.core.Presences.Companion.UninitializedPresences import dev.yorkie.core.Presences.Companion.asPresences import dev.yorkie.document.Document.Event.PresenceChange.MyPresence import dev.yorkie.document.Document.Event.PresenceChange.Others @@ -40,9 +39,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext @@ -83,14 +82,14 @@ public class Document(public val key: Key) { internal val garbageLength: Int get() = root.getGarbageLength() - internal val onlineClients = MutableStateFlow(setOf()) + internal val onlineClients = mutableSetOf() - private val _presences = MutableStateFlow(UninitializedPresences) - public val presences: StateFlow = - combine(_presences, onlineClients) { presences, onlineClients -> - presences.filterKeys { it in onlineClients }.asPresences() - }.stateIn(scope, SharingStarted.Eagerly, _presences.value.asPresences()) + private val _presences = MutableStateFlow(Presences()) + public val presences: StateFlow = _presences.map { presences -> + presences.filterKeys { it in onlineClients }.asPresences() + }.stateIn(scope, SharingStarted.Eagerly, _presences.value.asPresences()) + @VisibleForTesting internal val allPresences: StateFlow = _presences.asStateFlow() /** @@ -131,7 +130,7 @@ public class Document(public val key: Key) { val change = context.getChange() val (operationInfos, newPresences) = change.execute(root, _presences.value) - newPresences?.let { emitPresences(it) } + newPresences?.let { _presences.emit(it) } localChanges += change changeID = change.id @@ -264,31 +263,22 @@ public class Document(public val key: Key) { this.clone = clone.copy(presences = newPresences ?: return@also) } val actorID = change.id.actor - var presenceEvent: Event.PresenceChange? = null - if (change.hasPresenceChange && actorID in onlineClients.value) { + var event: Event? = null + if (change.hasPresenceChange && actorID in onlineClients) { val presenceChange = change.presenceChange ?: return@forEach - presenceEvent = when (presenceChange) { + event = when (presenceChange) { is PresenceChange.Put -> { if (actorID in _presences.value) { createPresenceChangedEvent(actorID, presenceChange.presence) } else { - // NOTE(chacha912): When the user exists in onlineClients, but - // their presence was initially absent, we can consider that we have - // received their initial presence, so trigger the 'watched' event. Others.Watched(PresenceInfo(actorID, presenceChange.presence)) } } is PresenceChange.Clear -> { - // NOTE(chacha912): When the user exists in onlineClients, but - // PresenceChange(clear) is received, we can consider it as detachment - // occurring before unwatching. - // Detached user is no longer participating in the document, we remove - // them from the online clients and trigger the 'unwatched' event. + onlineClients.remove(actorID) presences.value[actorID]?.let { presence -> Others.Unwatched(PresenceInfo(actorID, presence)) - }.also { - onlineClients.value = onlineClients.value - actorID } } } @@ -299,8 +289,8 @@ public class Document(public val key: Key) { eventStream.emit(Event.RemoteChange(change.toChangeInfo(opInfos))) } - newPresences?.let { emitPresences(it) } - presenceEvent?.let { eventStream.emit(it) } + event?.let { eventStream.emit(it) } + newPresences?.let { _presences.emit(it) } changeID = changeID.syncLamport(change.id.lamport) } } @@ -309,11 +299,6 @@ public class Document(public val key: Key) { clone ?: RootClone(root.deepCopy(), _presences.value.asPresences()).also { clone = it } } - private suspend fun emitPresences(newPresences: Presences) { - _presences.emit(newPresences) - clone = ensureClone().copy(presences = newPresences) - } - /** * Create [ChangePack] of [localChanges] to send to the remote server. */ diff --git a/yorkie/src/main/kotlin/dev/yorkie/document/JsonSerializableStruct.kt b/yorkie/src/main/kotlin/dev/yorkie/document/JsonSerializableStruct.kt index 41170b644..1afb5a88a 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/document/JsonSerializableStruct.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/document/JsonSerializableStruct.kt @@ -1,6 +1,5 @@ package dev.yorkie.document -import dev.yorkie.document.crdt.CrdtTreeNodeID import dev.yorkie.document.crdt.CrdtTreePos import dev.yorkie.document.crdt.RgaTreeSplitNodeID import dev.yorkie.document.crdt.RgaTreeSplitPos @@ -17,27 +16,17 @@ internal interface JsonSerializable> { fun toStruct(): O } -public data class CrdtTreePosStruct( - val parentID: CrdtTreeNodeIDStruct, - val leftSiblingID: CrdtTreeNodeIDStruct, -) : JsonSerializable.Struct { - - override fun toOriginal(): CrdtTreePos { - return CrdtTreePos(parentID.toOriginal(), leftSiblingID.toOriginal()) - } -} - /** - * [CrdtTreeNodeIDStruct] represents the structure of [CrdtTreeNodeID]. + * [CrdtTreePosStruct] represents the structure of [CrdtTreePos]. * It is used to serialize and deserialize the CRDTTreePos. */ -public data class CrdtTreeNodeIDStruct( +public data class CrdtTreePosStruct( val createdAt: TimeTicketStruct, val offset: Int, -) : JsonSerializable.Struct { +) : JsonSerializable.Struct { - override fun toOriginal(): CrdtTreeNodeID { - return CrdtTreeNodeID(createdAt.toOriginal(), offset) + override fun toOriginal(): CrdtTreePos { + return CrdtTreePos(createdAt.toOriginal(), offset) } } diff --git a/yorkie/src/main/kotlin/dev/yorkie/document/crdt/CrdtText.kt b/yorkie/src/main/kotlin/dev/yorkie/document/crdt/CrdtText.kt index 241a63f1f..94118926c 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/document/crdt/CrdtText.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/document/crdt/CrdtText.kt @@ -12,6 +12,8 @@ internal data class CrdtText( override var _movedAt: TimeTicket? = null, override var _removedAt: TimeTicket? = null, ) : CrdtGCElement() { + private val selectionMap = mutableMapOf() + override val removedNodesLength: Int get() = rgaTreeSplit.removedNodesLength @@ -66,6 +68,17 @@ internal data class CrdtText( return Triple(latestCreatedAtMap, changes, caretPos to caretPos) } + private fun selectPrev(range: RgaTreeSplitPosRange, executedAt: TimeTicket): TextChange? { + val prevSelection = selectionMap[executedAt.actorID] + return if (prevSelection == null || prevSelection.executedAt < executedAt) { + selectionMap[executedAt.actorID] = Selection(range.first, range.second, executedAt) + val (from, to) = rgaTreeSplit.findIndexesFromRange(range) + TextChange(TextChangeType.Selection, executedAt.actorID, from, to) + } else { + null + } + } + /** * Applies the style of the given [range]. * 1. Split nodes with from and to. @@ -97,6 +110,13 @@ internal data class CrdtText( } } + /** + * Stores that the given [range] has been selected. + */ + fun select(range: RgaTreeSplitPosRange, executedAt: TimeTicket): TextChange? { + return selectPrev(range, executedAt) + } + /** * Returns a pair of [RgaTreeSplitPos] of the given integer offsets. */ diff --git a/yorkie/src/main/kotlin/dev/yorkie/document/crdt/CrdtTree.kt b/yorkie/src/main/kotlin/dev/yorkie/document/crdt/CrdtTree.kt index c94869b2c..a16c10789 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/document/crdt/CrdtTree.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/document/crdt/CrdtTree.kt @@ -1,18 +1,16 @@ package dev.yorkie.document.crdt -import dev.yorkie.document.CrdtTreeNodeIDStruct import dev.yorkie.document.CrdtTreePosStruct import dev.yorkie.document.JsonSerializable +import dev.yorkie.document.crdt.CrdtTreeNode.Companion.CrdtTreeElement import dev.yorkie.document.json.TreePosStructRange -import dev.yorkie.document.time.ActorID import dev.yorkie.document.time.TimeTicket import dev.yorkie.document.time.TimeTicket.Companion.InitialTimeTicket -import dev.yorkie.document.time.TimeTicket.Companion.MaxTimeTicket import dev.yorkie.document.time.TimeTicket.Companion.compareTo import dev.yorkie.util.IndexTree import dev.yorkie.util.IndexTreeNode -import dev.yorkie.util.TagContained import dev.yorkie.util.TreePos +import dev.yorkie.util.traverse import java.util.TreeMap public typealias TreePosRange = Pair @@ -22,26 +20,49 @@ internal class CrdtTree( override val createdAt: TimeTicket, override var _movedAt: TimeTicket? = null, override var _removedAt: TimeTicket? = null, -) : CrdtGCElement() { +) : CrdtGCElement(), Collection { + + private val head = CrdtTreeElement(CrdtTreePos.InitialCrdtTreePos, INITIAL_NODE_TYPE) internal val indexTree = IndexTree(root) - private val nodeMapByID = TreeMap() + private val nodeMapByPos = TreeMap() private val removedNodeMap = mutableMapOf, CrdtTreeNode>() init { + var previous = head indexTree.traverse { node, _ -> - nodeMapByID[node.id] = node + insertAfter(previous, node) + previous = node } } override val removedNodesLength: Int get() = removedNodeMap.size - val size: Int + override val size: Int get() = indexTree.size + /** + * Returns the nodes between the given range. + */ + fun nodesBetweenByTree( + from: Int, + to: Int, + action: ((CrdtTreeNode) -> Unit), + ) { + indexTree.nodesBetween(from, to, action) + } + + /** + * Finds the right node of the given [index] in postorder. + */ + fun findPostorderRight(index: Int): CrdtTreeNode? { + val pos = indexTree.findTreePos(index, true) + return indexTree.findPostorderRight(pos) + } + /** * Applies the given [attributes] of the given [range]. */ @@ -50,28 +71,22 @@ internal class CrdtTree( attributes: Map?, executedAt: TimeTicket, ): List { - val (fromParent, fromLeft) = findNodesAndSplitText(range.first, executedAt) - val (toParent, toLeft) = findNodesAndSplitText(range.second, executedAt) - // TODO(7hong13): check whether toPath is set correctly + val (_, toRight) = findTreePos(range.second, executedAt) + val (_, fromRight) = findTreePos(range.first, executedAt) val changes = listOf( TreeChange( type = TreeChangeType.Style.type, - from = toIndex(fromParent, fromLeft), - to = toIndex(toParent, toLeft), - fromPath = toPath(fromParent, fromLeft), - toPath = toPath(toParent, toLeft), + from = toIndex(range.first), + to = toIndex(range.second), + fromPath = indexTree.indexToPath(posToStartIndex(range.first)), + toPath = indexTree.indexToPath(posToStartIndex(range.first)), actorID = executedAt.actorID, attributes = attributes, ), ) - traverseInPosRange( - fromParent = fromParent, - fromLeft = fromLeft, - toParent = toParent, - toLeft = toLeft, - ) { node, _ -> - if (!node.isRemoved && attributes != null && !node.isText) { + nodesBetween(fromRight, toRight) { node -> + if (!node.isRemoved && attributes != null) { attributes.forEach { (key, value) -> node.setAttribute(key, value, executedAt) } @@ -81,40 +96,43 @@ internal class CrdtTree( return changes } - private fun toPath(parentNode: CrdtTreeNode, leftSiblingNode: CrdtTreeNode): List { - return indexTree.treePosToPath(toTreePos(parentNode, leftSiblingNode)) - } - - private fun toTreePos( - parentNode: CrdtTreeNode, - leftSiblingNode: CrdtTreeNode, - ): TreePos { - return when { - parentNode.isRemoved -> { - var child = parentNode - var parent = parentNode - while (parent.isRemoved) { - child = parent - parent = child.parent ?: break - } - - val childOffset = parent.findOffset(child) - TreePos(parent, childOffset) - } - - parentNode == leftSiblingNode -> TreePos(parentNode, 0) + /** + * Finds [TreePos] of the given [CrdtTreePos]. + */ + private fun findTreePos( + pos: CrdtTreePos, + executedAt: TimeTicket, + ): Pair, CrdtTreeNode> { + val treePos = toTreePos(pos) ?: throw IllegalArgumentException("cannot find node at $pos") - else -> { - var offset = parentNode.findOffset(leftSiblingNode) - if (!leftSiblingNode.isRemoved) { - if (leftSiblingNode.isText) { - return TreePos(leftSiblingNode, leftSiblingNode.paddedSize) - } else { - offset++ - } + // Find the appropriate position. This logic is similar to + // handling the insertion of the same position in RGA. + var current = treePos + while (executedAt < current.node.next?.pos?.createdAt && + current.node.parent == current.node.next?.parent + ) { + val nextNode = current.node.next ?: break + current = TreePos(nextNode, nextNode.size) + } + val right = requireNotNull(indexTree.findPostorderRight(treePos)) + return current to right + } + + private fun toTreePos(pos: CrdtTreePos): TreePos? { + val (key, value) = nodeMapByPos.floorEntry(pos) ?: return null + return if (key?.createdAt == pos.createdAt) { + val node = + // Choose the left node if the position is on the boundary of the split nodes. + if (pos.offset > 0 && pos.offset == value.pos.offset && + value.insertionPrev != null + ) { + value.insertionPrev + } else { + value } - TreePos(parentNode, offset) - } + TreePos(requireNotNull(node), pos.offset - node.pos.offset) + } else { + null } } @@ -126,115 +144,88 @@ internal class CrdtTree( range: TreePosRange, contents: List?, executedAt: TimeTicket, - latestCreatedAtMapByActor: Map? = null, - ): Pair, Map> { + ): List { // 01. split text nodes at the given range if needed. - val (fromParent, fromLeft) = findNodesAndSplitText(range.first, executedAt) - val (toParent, toLeft) = findNodesAndSplitText(range.second, executedAt) + val (toPos, toRight) = findTreePosWithSplitText(range.second, executedAt) + val (fromPos, fromRight) = findTreePosWithSplitText(range.first, executedAt) // NOTE(hackerwins): If concurrent deletion happens, we need to separate the // range(from, to) into multiple ranges. val changes = listOf( TreeChange( type = TreeChangeType.Content.type, - from = toIndex(fromParent, fromLeft), - to = toIndex(toParent, toLeft), - fromPath = toPath(fromParent, fromLeft), - toPath = toPath(toParent, toLeft), + from = toIndex(range.first), + to = toIndex(range.second), + fromPath = indexTree.treePosToPath(fromPos), + toPath = indexTree.treePosToPath(toPos), actorID = executedAt.actorID, value = contents?.map(CrdtTreeNode::toJson), ), ) val toBeRemoved = mutableListOf() - val latestCreatedAtMap = mutableMapOf() - - traverseInPosRange( - fromParent = fromParent, - fromLeft = fromLeft, - toParent = toParent, - toLeft = toLeft, - ) { node, contained -> - // If node is a element node and half-contained in the range, - // it should not be removed. - if (!node.isText && contained != TagContained.All) { - return@traverseInPosRange + // 02. remove the nodes and update linked list and index tree. + if (fromRight != toRight) { + nodesBetween(fromRight, toRight) { node -> + if (!node.isRemoved) { + toBeRemoved.add(node) + } } - val actorID = node.createdAt.actorID - val latestCreatedAt = latestCreatedAtMapByActor?.let { - latestCreatedAtMapByActor[actorID] ?: InitialTimeTicket - } ?: MaxTimeTicket - - if (node.canDelete(executedAt, latestCreatedAt)) { - val latest = latestCreatedAtMap[actorID] - val createdAt = node.createdAt - - if (latest == null || latest < createdAt) { - latestCreatedAtMap[actorID] = createdAt + val isRangeOnSameBranch = toPos.node.isAncestorOf(fromPos.node) + toBeRemoved.forEach { node -> + node.remove(executedAt) + if (node.isRemoved) { + removedNodeMap[node.createdAt to node.pos.offset] = node } - - toBeRemoved.add(node) } - } - toBeRemoved.forEach { node -> - node.remove(executedAt) - if (node.isRemoved) { - removedNodeMap[node.createdAt to node.id.offset] = node + // move the alive children of the removed element node + if (isRangeOnSameBranch) { + val removedElementNode = when { + fromPos.node.parent?.isRemoved == true -> fromPos.node.parent + !fromPos.node.isText && fromPos.node.isRemoved -> fromPos.node + else -> null + } + removedElementNode?.let { removedNode -> + val elementNode = toPos.node + val offset = elementNode.findBranchOffset(removedNode) + removedNode.children.reversed().forEach { node -> + elementNode.insertAt(offset, node) + } + } + } else if (fromPos.node.parent?.isRemoved == true) { + val parent = requireNotNull(fromPos.node.parent) + toPos.node.parent?.prepend(*parent.children.toTypedArray()) } } // 03. insert the given node at the given position. if (contents?.isNotEmpty() == true) { - var leftInChildren = fromLeft - + // 03-1. insert the content nodes to the list. + var previous = requireNotNull(fromRight.prev) + var offset = fromPos.offset contents.forEach { content -> - // 03-1. insert the content nodes to the list. - if (leftInChildren == fromParent) { - // 03-1-1. when there's no leftSibling, then insert content into very front of parent's children List - fromParent.insertAt(0, content) - } else { - // 03-1-2. insert after leftSibling - fromParent.insertAfter(leftInChildren, content) + traverse(content) { node, _ -> + insertAfter(previous, node) + previous = node } - leftInChildren = content - traverseAll(content) { node, _ -> - // if insertion happens during concurrent editing and parent node has been removed, - // make new nodes as tombstone immediately - if (fromParent.isRemoved) { - node.remove(executedAt) - removedNodeMap[node.id.createdAt to node.id.offset] = node + // 03-2. insert the content nodes to the tree. + val node = fromPos.node + if (node.isText) { + if (fromPos.offset == 0) { + node.parent?.insertBefore(node, content) + } else { + node.parent?.insertAfter(node, content) } - nodeMapByID[node.id] = node + } else { + node.insertAt(offset, content) + offset++ } } } - return changes to latestCreatedAtMap - } - - private fun traverseAll( - node: CrdtTreeNode, - depth: Int = 0, - action: ((CrdtTreeNode, Int) -> Unit), - ) { - node.allChildren.forEach { child -> - traverseAll(child, depth + 1, action) - } - action.invoke(node, depth) - } - - private fun traverseInPosRange( - fromParent: CrdtTreeNode, - fromLeft: CrdtTreeNode, - toParent: CrdtTreeNode, - toLeft: CrdtTreeNode, - callback: (CrdtTreeNode, TagContained) -> Unit, - ) { - val fromIndex = toIndex(fromParent, fromLeft) - val toIndex = toIndex(toParent, toLeft) - indexTree.nodesBetween(fromIndex, toIndex, callback) + return changes } /** @@ -243,70 +234,81 @@ internal class CrdtTree( * [CrdtTreePos] is a position in the CRDT perspective. This is * different from [TreePos] which is a position of the tree in the local perspective. */ - fun findNodesAndSplitText( + private fun findTreePosWithSplitText( pos: CrdtTreePos, executedAt: TimeTicket, - ): Pair { - val treeNodes = - toTreeNodes(pos) ?: throw IllegalArgumentException("cannot find node at $pos") - val (parentNode, leftSiblingNode) = treeNodes + ): Pair, CrdtTreeNode> { + val treePos = toTreePos(pos) ?: throw IllegalArgumentException("cannot find node at $pos") // Find the appropriate position. This logic is similar to // handling the insertion of the same position in RGA. - if (leftSiblingNode.isText) { - val absOffset = leftSiblingNode.id.offset - val split = leftSiblingNode.split(pos.leftSiblingID.offset - absOffset, absOffset) - split?.let { - split.insPrevID = leftSiblingNode.id - nodeMapByID[split.id] = split - - leftSiblingNode.insNextID?.let { insNextID -> - val insNext = findFloorNode(insNextID) - insNext?.insPrevID = split.id - split.insNextID = insNextID - } + var current = treePos + while (executedAt < current.node.next?.pos?.createdAt && + current.node.parent == current.node.next?.parent + ) { + val nextNode = current.node.next ?: break + current = TreePos(nextNode, nextNode.size) + } - leftSiblingNode.insNextID = split.id + if (current.node.isText) { + current.node.split(current.offset)?.let { split -> + insertAfter(current.node, split) + split.insertionPrev = current.node } } - val index = if (parentNode == leftSiblingNode) { - 0 - } else { - parentNode.allChildren.indexOf(leftSiblingNode) + 1 - } - var updatedLeftSiblingNode = leftSiblingNode - for (i in index until parentNode.allChildren.size) { - val next = parentNode.allChildren[i] - if (executedAt < next.id.createdAt) { - updatedLeftSiblingNode = next - } else { - break - } + val right = requireNotNull(indexTree.findPostorderRight(treePos)) + return current to right + } + + /** + * Inserts the [newNode] after the [prevNode] + */ + private fun insertAfter(prevNode: CrdtTreeNode, newNode: CrdtTreeNode) { + val next = prevNode.next + prevNode.next = newNode + newNode.prev = prevNode + next?.let { + newNode.next = next + next.prev = newNode } - return parentNode to updatedLeftSiblingNode + nodeMapByPos[newNode.pos] = newNode } - private fun findFloorNode(id: CrdtTreeNodeID): CrdtTreeNode? { - val (key, value) = nodeMapByID.floorEntry(id) ?: return null - return if (key == null || key.createdAt != id.createdAt) null else value + /** + * Returns the nodes between the given range. + * [left] is inclusive, while [right] is exclusive. + */ + private fun nodesBetween( + left: CrdtTreeNode, + right: CrdtTreeNode, + action: (CrdtTreeNode) -> Unit, + ) { + var current: CrdtTreeNode? = left + while (current != right) { + current?.let(action) + ?: throw IllegalArgumentException("left and right are not in the same list") + current = current.next + } } - private fun toTreeNodes(pos: CrdtTreePos): Pair? { - val parentID = pos.parentID - val leftSiblingID = pos.leftSiblingID - val parentNode = findFloorNode(parentID) ?: return null - val leftSiblingNode = findFloorNode(leftSiblingID) ?: return null + /** + * Returns start index of pos + * 0 1 2 3 4 5 6 7 8 + *

t e x t

+ * If tree is just like above, and the pos is pointing index of 7 + * this returns 0 (start index of tag) + */ + private fun posToStartIndex(pos: CrdtTreePos): Int { + val treePos = toTreePos(pos) + val index = toIndex(pos) + val size = if (treePos?.node?.isText == true) { + treePos.node.parent?.size + } else { + treePos?.node?.size + } ?: -1 - val updatedLeftSiblingNode = - if (leftSiblingID.offset > 0 && leftSiblingID.offset == leftSiblingNode.id.offset && - leftSiblingNode.insPrevID != null - ) { - findFloorNode(requireNotNull(leftSiblingNode.insPrevID)) ?: leftSiblingNode - } else { - leftSiblingNode - } - return parentNode to updatedLeftSiblingNode + return index - size - 1 } /** @@ -351,9 +353,9 @@ internal class CrdtTree( nodesToBeRemoved.forEach { node -> node.parent?.removeChild(node) - nodeMapByID.remove(node.id) + nodeMapByPos.remove(node.pos) delete(node) - removedNodeMap.remove(node.createdAt to node.id.offset) + removedNodeMap.remove(node.createdAt to node.pos.offset) } return nodesToBeRemoved.size @@ -363,20 +365,14 @@ internal class CrdtTree( * Physically deletes the given [node] from [IndexTree]. */ private fun delete(node: CrdtTreeNode) { - val insPrevID = node.insPrevID - val insNextID = node.insNextID - - insPrevID?.let { - val insPrev = findFloorNode(it) - insPrev?.insNextID = insNextID - } - insNextID?.let { - val insNext = findFloorNode(it) - insNext?.insPrevID = insPrevID - } + val prev = node.prev + val next = node.next + prev?.next = next + next?.prev = prev - node.insPrevID = null - node.insNextID = null + node.prev = null + node.next = null + node.insertionPrev = null } /** @@ -384,21 +380,7 @@ internal class CrdtTree( */ fun findPos(index: Int, preferText: Boolean = true): CrdtTreePos { val (node, offset) = indexTree.findTreePos(index, preferText) - var updatedNode = node - val leftSibling = if (node.isText) { - updatedNode = requireNotNull(node.parent) - if (node.parent?.children?.firstOrNull() == node && offset == 0) { - node.parent - } else { - node - } - } else { - if (offset == 0) node else node.children[offset - 1] - } ?: throw IllegalArgumentException("left sibling should not be null") - return CrdtTreePos( - updatedNode.id, - CrdtTreeNodeID(leftSibling.createdAt, leftSibling.offset + offset), - ) + return CrdtTreePos(node.pos.createdAt, node.pos.offset + offset) } /** @@ -411,15 +393,23 @@ internal class CrdtTree( /** * Converts the given [pos] to the index of the tree. */ - fun toIndex(parentNode: CrdtTreeNode, leftSiblingNode: CrdtTreeNode): Int { - return indexTree.indexOf(toTreePos(parentNode, leftSiblingNode)) + fun toIndex(pos: CrdtTreePos): Int { + return toTreePos(pos)?.let(indexTree::indexOf) ?: -1 } /** * Converts the given path of the node to the range of the position. */ fun pathToPosRange(path: List): TreePosRange { - val fromIndex = pathToIndex(path) + val index = pathToIndex(path) + val (parentNode, offset) = pathToTreePos(path) + + if (parentNode.hasTextChild) { + throw IllegalArgumentException("invalid path: $path") + } + + val node = parentNode.children[offset] + val fromIndex = index + node.size + 1 return findPos(fromIndex) to findPos(fromIndex + 1) } @@ -434,7 +424,8 @@ internal class CrdtTree( * Finds the position of the given index in the tree by [path]. */ fun pathToPos(path: List): CrdtTreePos { - return findPos(indexTree.pathToIndex(path)) + val (node, offset) = indexTree.pathToTreePos(path) + return CrdtTreePos(node.pos.createdAt, node.pos.offset + offset) } /** @@ -475,37 +466,60 @@ internal class CrdtTree( * Converts the [range] into [TreePosStructRange]. */ fun indexRangeToPosStructRange(range: Pair): TreePosStructRange { - val (fromIndex, toIndex) = range - val fromPos = findPos(fromIndex) - return if (fromIndex == toIndex) { - fromPos.toStruct() to fromPos.toStruct() - } else { - fromPos.toStruct() to findPos(toIndex).toStruct() - } + val (fromPos, toPos) = indexRangeToPosRange(range) + return fromPos.toStruct() to toPos.toStruct() } /** - * Converts the given position [range] to the path range. + * Returns a pair of integer offsets of the tree. */ - fun posRangeToPathRange( - range: TreePosRange, - executedAt: TimeTicket, - ): Pair, List> { - val (fromParent, fromLeft) = findNodesAndSplitText(range.first, executedAt) - val (toParent, toLeft) = findNodesAndSplitText(range.second, executedAt) - return toPath(fromParent, fromLeft) to toPath(toParent, toLeft) + fun rangeToIndex(range: TreePosRange): Pair { + return toIndex(range.first) to toIndex(range.second) } /** - * Converts the given position range to the path range. + * Converts the given position [range] to the path range. */ - fun posRangeToIndexRange( - range: TreePosRange, - executedAt: TimeTicket, - ): Pair { - val (fromParent, fromLeft) = findNodesAndSplitText(range.first, executedAt) - val (toParent, toLeft) = findNodesAndSplitText(range.second, executedAt) - return toIndex(fromParent, fromLeft) to toIndex(toParent, toLeft) + fun posRangeToPathRange(range: TreePosRange): Pair, List> { + val fromPath = indexTree.indexToPath(toIndex(range.first)) + val toPath = indexTree.indexToPath(toIndex(range.second)) + return fromPath to toPath + } + + override fun isEmpty() = indexTree.size == 0 + + override fun iterator(): Iterator { + return object : Iterator { + var node = head.next + + override fun hasNext(): Boolean { + while (node != null) { + if (node?.isRemoved == false) { + return true + } + node = node?.next + } + return false + } + + override fun next(): CrdtTreeNode { + return requireNotNull(node).also { + node = node?.next + } + } + } + } + + override fun containsAll(elements: Collection): Boolean { + return indexTree.root.children.containsAll(elements) + } + + override fun contains(element: CrdtTreeNode): Boolean { + return indexTree.root.children.contains(element) + } + + companion object { + private const val INITIAL_NODE_TYPE = "dummy" } } @@ -515,7 +529,7 @@ internal class CrdtTree( */ @Suppress("DataClassPrivateConstructor") internal data class CrdtTreeNode private constructor( - val id: CrdtTreeNodeID, + val pos: CrdtTreePos, override val type: String, private val _value: String? = null, private val childNodes: MutableList = mutableListOf(), @@ -529,7 +543,7 @@ internal data class CrdtTreeNode private constructor( get() = _attributes.toXml() val createdAt: TimeTicket - get() = id.createdAt + get() = pos.createdAt var removedAt: TimeTicket? = null private set @@ -537,9 +551,6 @@ internal data class CrdtTreeNode private constructor( override val isRemoved: Boolean get() = removedAt != null - val offset: Int - get() = id.offset - override var value: String = _value.orEmpty() get() { check(isText) { @@ -555,9 +566,11 @@ internal data class CrdtTreeNode private constructor( size = value.length } - var insPrevID: CrdtTreeNodeID? = null + var next: CrdtTreeNode? = null + + var prev: CrdtTreeNode? = null - var insNextID: CrdtTreeNodeID? = null + var insertionPrev: CrdtTreeNode? = null val rhtNodes: List get() = _attributes.toList() @@ -570,13 +583,7 @@ internal data class CrdtTreeNode private constructor( * Clones this node with the given [offset]. */ override fun clone(offset: Int): CrdtTreeNode { - return copy( - id = CrdtTreeNodeID(id.createdAt, offset), - _value = null, - childNodes = mutableListOf(), - ).also { - it.removedAt = this.removedAt - } + return copy(pos = CrdtTreePos(pos.createdAt, offset)) } fun setAttribute(key: String, value: String, executedAt: TimeTicket) { @@ -616,53 +623,28 @@ internal data class CrdtTreeNode private constructor( } } - /** - * Checks if node is able to delete. - */ - fun canDelete(executedAt: TimeTicket, latestCreatedAt: TimeTicket): Boolean { - return createdAt <= latestCreatedAt && (removedAt == null || removedAt < executedAt) - } - @Suppress("FunctionName") companion object { - fun CrdtTreeText(id: CrdtTreeNodeID, value: String): CrdtTreeNode { - return CrdtTreeNode(id, DEFAULT_TEXT_TYPE, value) + fun CrdtTreeText(pos: CrdtTreePos, value: String): CrdtTreeNode { + return CrdtTreeNode(pos, DEFAULT_TEXT_TYPE, value) } fun CrdtTreeElement( - id: CrdtTreeNodeID, + pos: CrdtTreePos, type: String, children: List = emptyList(), attributes: Rht = Rht(), - ) = CrdtTreeNode(id, type, null, children.toMutableList(), attributes) + ) = CrdtTreeNode(pos, type, null, children.toMutableList(), attributes) } } /** - * [CrdtTreePos] represent a position in the tree. It is used to identify a - * position in the tree. It is composed of the parent ID and the left sibling ID. - * If there's no left sibling in parent's children, then left sibling is parent. + * [CrdtTreePos] represents a position in the tree. It indicates the virtual + * location in the tree, so whether the node is split or not, we can find + * the adjacent node to pos by calling `map.floorEntry()`. */ -public data class CrdtTreePos internal constructor( - val parentID: CrdtTreeNodeID, - val leftSiblingID: CrdtTreeNodeID, -) : JsonSerializable { - - override fun toStruct(): CrdtTreePosStruct { - return CrdtTreePosStruct(parentID.toStruct(), leftSiblingID.toStruct()) - } -} - -/** - * [CrdtTreeNodeID] represent an ID of a node in the tree. It is used to - * identify a node in the tree. It is composed of the creation time of the node - * and the offset from the beginning of the node if the node is split. - * - * Some of replicas may have nodes that are not split yet. In this case, we can - * use `map.floorEntry()` to find the adjacent node. - */ -public data class CrdtTreeNodeID internal constructor( +public data class CrdtTreePos( /** * Creation time of the node. */ @@ -672,17 +654,17 @@ public data class CrdtTreeNodeID internal constructor( * The distance from the beginning of the node when the node is split. */ val offset: Int, -) : Comparable, JsonSerializable { +) : Comparable, JsonSerializable { - override fun compareTo(other: CrdtTreeNodeID): Int { + override fun compareTo(other: CrdtTreePos): Int { return compareValuesBy(this, other, { it.createdAt }, { it.offset }) } - override fun toStruct(): CrdtTreeNodeIDStruct { - return CrdtTreeNodeIDStruct(createdAt.toStruct(), offset) + override fun toStruct(): CrdtTreePosStruct { + return CrdtTreePosStruct(createdAt.toStruct(), offset) } companion object { - internal val InitialCrdtTreeNodeID = CrdtTreeNodeID(InitialTimeTicket, 0) + internal val InitialCrdtTreePos = CrdtTreePos(InitialTimeTicket, 0) } } diff --git a/yorkie/src/main/kotlin/dev/yorkie/document/crdt/TextInfo.kt b/yorkie/src/main/kotlin/dev/yorkie/document/crdt/TextInfo.kt index bdd447a30..8f51d60a7 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/document/crdt/TextInfo.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/document/crdt/TextInfo.kt @@ -17,9 +17,18 @@ internal data class TextChange( * The type of [TextChange]. */ internal enum class TextChangeType { - Content, Style + Content, Selection, Style } +/** + * Represents the selection of text range in the editor. + */ +internal data class Selection( + val from: RgaTreeSplitPos, + val to: RgaTreeSplitPos, + val executedAt: TimeTicket, +) + internal data class TextValue( val content: String, private val _attributes: Rht = Rht(), diff --git a/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonElement.kt b/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonElement.kt index b85db0e34..121fe13d9 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonElement.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonElement.kt @@ -31,7 +31,6 @@ public abstract class JsonElement { CrdtText::class.java to JsonText::class.java, CrdtPrimitive::class.java to JsonPrimitive::class.java, CrdtCounter::class.java to JsonCounter::class.java, - CrdtTree::class.java to JsonTree::class.java, ) internal inline fun CrdtElement.toJsonElement( diff --git a/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonObject.kt b/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonObject.kt index 11433d83e..c140f7d55 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonObject.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonObject.kt @@ -113,7 +113,7 @@ public class JsonObject internal constructor( val ticket = context.issueTimeTicket() val tree = CrdtTree(JsonTree.buildRoot(initialRoot, context), ticket) setAndRegister(key, tree) - return tree.toJsonElement(context) + return JsonTree(context, tree) } private fun setAndRegister(key: String, element: CrdtElement) { diff --git a/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonText.kt b/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonText.kt index 4b4efcaea..1fa6b46ec 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonText.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonText.kt @@ -5,6 +5,7 @@ import dev.yorkie.document.crdt.CrdtText import dev.yorkie.document.crdt.RgaTreeSplitPosRange import dev.yorkie.document.crdt.TextWithAttributes import dev.yorkie.document.operation.EditOperation +import dev.yorkie.document.operation.SelectOperation import dev.yorkie.document.operation.StyleOperation import dev.yorkie.util.YorkieLogger import dev.yorkie.document.RgaTreeSplitPosStruct as TextPosStruct @@ -124,6 +125,41 @@ public class JsonText internal constructor( return true } + /** + * Selects the given range. + */ + public fun select(fromIndex: Int, toIndex: Int): Boolean { + if (fromIndex > toIndex) { + YorkieLogger.e(TAG, "fromIndex should be less than or equal to toIndex") + return false + } + + val range = createRange(fromIndex, toIndex) ?: return false + + YorkieLogger.d( + TAG, + "SELT: f:$fromIndex->${range.first}, t:$toIndex->${range.second}", + ) + + val executedAt = context.issueTimeTicket() + try { + target.select(range, executedAt) + } catch (e: NoSuchElementException) { + YorkieLogger.e(TAG, "can't select text") + return false + } + + context.push( + SelectOperation( + parentCreatedAt = target.createdAt, + fromPos = range.first, + toPos = range.second, + executedAt = executedAt, + ), + ) + return true + } + private fun createRange(fromIndex: Int, toIndex: Int): RgaTreeSplitPosRange? { return runCatching { target.indexRangeToPosRange(fromIndex, toIndex) diff --git a/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonTree.kt b/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonTree.kt index 67ad12a3b..d3cec3252 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonTree.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/document/json/JsonTree.kt @@ -5,10 +5,10 @@ import dev.yorkie.document.crdt.CrdtTree import dev.yorkie.document.crdt.CrdtTreeNode import dev.yorkie.document.crdt.CrdtTreeNode.Companion.CrdtTreeElement import dev.yorkie.document.crdt.CrdtTreeNode.Companion.CrdtTreeText -import dev.yorkie.document.crdt.CrdtTreeNodeID import dev.yorkie.document.crdt.CrdtTreePos import dev.yorkie.document.crdt.Rht import dev.yorkie.document.crdt.TreePosRange +import dev.yorkie.document.json.JsonTree.TreeNode import dev.yorkie.document.operation.TreeEditOperation import dev.yorkie.document.operation.TreeStyleOperation import dev.yorkie.util.IndexTreeNode.Companion.DEFAULT_ROOT_TYPE @@ -24,8 +24,8 @@ public typealias TreePosStructRange = Pair public class JsonTree internal constructor( internal val context: ChangeContext, override val target: CrdtTree, -) : JsonElement() { - public val size: Int by target::size +) : JsonElement(), Collection { + public override val size: Int by target::size internal val indexTree by target::indexTree @@ -105,28 +105,26 @@ public class JsonTree internal constructor( editByPos(fromPos, toPos, contents.toList()) } - private fun editByPos( - fromPos: CrdtTreePos, - toPos: CrdtTreePos, - contents: List, - ) { + private fun editByPos(fromPos: CrdtTreePos, toPos: CrdtTreePos, contents: List) { if (contents.isNotEmpty()) { validateTreeNodes(contents) if (contents.first().type != DEFAULT_TEXT_TYPE) { - contents.forEach { validateTreeNodes(listOf(it)) } + val children = + contents.filterIsInstance().flatMap(ElementNode::children) + validateTreeNodes(children) } } - val ticket = context.lastTimeTicket val crdtNodes = if (contents.firstOrNull()?.type == DEFAULT_TEXT_TYPE) { val compVal = contents .filterIsInstance() .joinToString("") { it.value } - listOf(CrdtTreeText(CrdtTreeNodeID(context.issueTimeTicket(), 0), compVal)) + listOf(CrdtTreeText(CrdtTreePos(context.issueTimeTicket(), 0), compVal)) } else { contents.map { createCrdtTreeNode(context, it) } } - val (_, maxCreatedAtMapByActor) = target.edit( + val ticket = context.lastTimeTicket + target.edit( fromPos to toPos, crdtNodes.map { it.deepCopy() }.ifEmpty { null }, ticket, @@ -137,13 +135,12 @@ public class JsonTree internal constructor( target.createdAt, fromPos, toPos, - maxCreatedAtMapByActor, crdtNodes.ifEmpty { null }, ticket, ), ) - if (fromPos != toPos) { + if (fromPos.createdAt != toPos.createdAt || fromPos.offset != toPos.offset) { context.registerElementHasRemovedNodes(target) } } @@ -221,7 +218,7 @@ public class JsonTree internal constructor( */ public fun posRangeToIndexRange(range: TreePosStructRange): Pair { val posRange = range.first.toOriginal() to range.second.toOriginal() - return target.posRangeToIndexRange(posRange, context.lastTimeTicket) + return target.toIndex(posRange.first) to target.toIndex(posRange.second) } /** @@ -229,7 +226,45 @@ public class JsonTree internal constructor( */ public fun posRangeToPathRange(range: TreePosStructRange): Pair, List> { val posRange = range.first.toOriginal() to range.second.toOriginal() - return target.posRangeToPathRange(posRange, context.lastTimeTicket) + return target.posRangeToPathRange(posRange) + } + + override fun isEmpty(): Boolean = target.isEmpty() + + override fun contains(element: TreeNode): Boolean { + return find { it == element } != null + } + + override fun containsAll(elements: Collection): Boolean { + return toList().containsAll(elements) + } + + override fun iterator(): Iterator { + return object : Iterator { + val targetIterator = target.iterator() + + override fun hasNext(): Boolean { + return targetIterator.hasNext() + } + + override fun next(): TreeNode { + return targetIterator.next().toTreeNode() + } + + private fun CrdtTreeNode.toTreeNode(): TreeNode { + return if (isText) { + TextNode(value) + } else { + ElementNode( + type, + attributes, + children.map { + it.toTreeNode() + }, + ) + } + } + } } companion object { @@ -238,12 +273,12 @@ public class JsonTree internal constructor( * Returns the root node of this tree. */ internal fun buildRoot(initialRoot: ElementNode?, context: ChangeContext): CrdtTreeNode { - val id = CrdtTreeNodeID(context.issueTimeTicket(), 0) + val pos = CrdtTreePos(context.issueTimeTicket(), 0) if (initialRoot == null) { - return CrdtTreeElement(id, DEFAULT_ROOT_TYPE) + return CrdtTreeElement(pos, DEFAULT_ROOT_TYPE) } // TODO(hackerwins): Need to use the ticket of operation of creating tree. - return CrdtTreeElement(id, initialRoot.type).also { root -> + return CrdtTreeElement(pos, initialRoot.type).also { root -> initialRoot.children.forEach { child -> buildDescendants(child, root, context) } @@ -257,11 +292,11 @@ public class JsonTree internal constructor( ) { val type = treeNode.type val ticket = context.issueTimeTicket() - val id = CrdtTreeNodeID(ticket, 0) + val pos = CrdtTreePos(ticket, 0) when (treeNode) { is TextNode -> { - val textNode = CrdtTreeText(id, treeNode.value) + val textNode = CrdtTreeText(pos, treeNode.value) parent.append(textNode) return } @@ -275,7 +310,7 @@ public class JsonTree internal constructor( attrs.set(key, value, ticket) } } - val elementNode = CrdtTreeElement(id, type, attributes = attrs) + val elementNode = CrdtTreeElement(pos, type, attributes = attrs) parent.append(elementNode) treeNode.children.forEach { child -> buildDescendants(child, elementNode, context) @@ -289,16 +324,16 @@ public class JsonTree internal constructor( */ private fun createCrdtTreeNode(context: ChangeContext, content: TreeNode): CrdtTreeNode { val ticket = context.issueTimeTicket() - val id = CrdtTreeNodeID(ticket, 0) + val pos = CrdtTreePos(ticket, 0) return when (content) { is TextNode -> { - CrdtTreeText(id, content.value) + CrdtTreeText(pos, content.value) } is ElementNode -> { CrdtTreeElement( - id, + pos, content.type, attributes = Rht().apply { content.attributes.forEach { (key, value) -> diff --git a/yorkie/src/main/kotlin/dev/yorkie/document/operation/OperationInfo.kt b/yorkie/src/main/kotlin/dev/yorkie/document/operation/OperationInfo.kt index 273e9a2a7..dfea2d74f 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/document/operation/OperationInfo.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/document/operation/OperationInfo.kt @@ -2,11 +2,6 @@ package dev.yorkie.document.operation import dev.yorkie.document.crdt.TextWithAttributes import dev.yorkie.document.crdt.TreeNode -import dev.yorkie.document.json.JsonArray -import dev.yorkie.document.json.JsonCounter -import dev.yorkie.document.json.JsonObject -import dev.yorkie.document.json.JsonText -import dev.yorkie.document.json.JsonTree import dev.yorkie.document.time.TimeTicket /** @@ -19,71 +14,48 @@ public sealed class OperationInfo { internal var executedAt: TimeTicket = TimeTicket.InitialTimeTicket - /** - * [TextOperationInfo] represents the [OperationInfo] for the [JsonText]. - */ - public interface TextOperationInfo - - /** - * [CounterOperationInfo] represents the [OperationInfo] for the [JsonCounter]. - */ - public interface CounterOperationInfo - - /** - * [ArrayOperationInfo] represents the [OperationInfo] for the [JsonArray]. - */ - public interface ArrayOperationInfo - - /** - * [ObjectOperationInfo] represents the [OperationInfo] for the [JsonObject]. - */ - public interface ObjectOperationInfo - - /** - * [TreeOperationInfo] represents the [OperationInfo] for the [JsonTree]. - */ - public interface TreeOperationInfo + public interface TextOpInfo public data class AddOpInfo(val index: Int, override var path: String = INITIAL_PATH) : - OperationInfo(), ArrayOperationInfo + OperationInfo() public data class MoveOpInfo( val previousIndex: Int, val index: Int, override var path: String = INITIAL_PATH, - ) : OperationInfo(), ArrayOperationInfo + ) : OperationInfo() public data class SetOpInfo(val key: String, override var path: String = INITIAL_PATH) : - OperationInfo(), ObjectOperationInfo + OperationInfo() public data class RemoveOpInfo( val key: String?, val index: Int?, override var path: String = INITIAL_PATH, - ) : OperationInfo(), ArrayOperationInfo, ObjectOperationInfo + ) : OperationInfo() public data class IncreaseOpInfo(val value: Number, override var path: String = INITIAL_PATH) : - OperationInfo(), CounterOperationInfo + OperationInfo() public data class EditOpInfo( val from: Int, val to: Int, val value: TextWithAttributes, override var path: String = INITIAL_PATH, - ) : OperationInfo(), TextOperationInfo + ) : OperationInfo(), TextOpInfo public data class StyleOpInfo( val from: Int, val to: Int, val attributes: Map, override var path: String = INITIAL_PATH, - ) : OperationInfo(), TextOperationInfo + ) : OperationInfo(), TextOpInfo public data class SelectOpInfo( val from: Int, val to: Int, override var path: String = INITIAL_PATH, - ) : OperationInfo(), TextOperationInfo + ) : OperationInfo(), TextOpInfo public data class TreeEditOpInfo( val from: Int, @@ -92,7 +64,7 @@ public sealed class OperationInfo { val toPath: List, val nodes: List?, override var path: String = INITIAL_PATH, - ) : OperationInfo(), TreeOperationInfo + ) : OperationInfo() public data class TreeStyleOpInfo( val from: Int, @@ -101,7 +73,7 @@ public sealed class OperationInfo { val toPath: List, val attributes: Map, override var path: String = INITIAL_PATH, - ) : OperationInfo(), TreeOperationInfo + ) : OperationInfo() companion object { private const val INITIAL_PATH = "initial path" diff --git a/yorkie/src/main/kotlin/dev/yorkie/document/operation/SelectOperation.kt b/yorkie/src/main/kotlin/dev/yorkie/document/operation/SelectOperation.kt new file mode 100644 index 000000000..96522dfed --- /dev/null +++ b/yorkie/src/main/kotlin/dev/yorkie/document/operation/SelectOperation.kt @@ -0,0 +1,43 @@ +package dev.yorkie.document.operation + +import dev.yorkie.document.crdt.CrdtRoot +import dev.yorkie.document.crdt.CrdtText +import dev.yorkie.document.crdt.RgaTreeSplitPos +import dev.yorkie.document.crdt.RgaTreeSplitPosRange +import dev.yorkie.document.time.TimeTicket +import dev.yorkie.util.YorkieLogger + +internal data class SelectOperation( + val fromPos: RgaTreeSplitPos, + val toPos: RgaTreeSplitPos, + override val parentCreatedAt: TimeTicket, + override var executedAt: TimeTicket, +) : Operation() { + + override val effectedCreatedAt: TimeTicket + get() = parentCreatedAt + + /** + * Returns the created time of the effected element. + */ + override fun execute(root: CrdtRoot): List { + val parentObject = root.findByCreatedAt(parentCreatedAt) + return if (parentObject is CrdtText) { + val change = parentObject.select(RgaTreeSplitPosRange(fromPos, toPos), executedAt) + ?: return emptyList() + listOf( + OperationInfo.SelectOpInfo(from = change.from, to = change.to).apply { + executedAt = parentCreatedAt + }, + ) + } else { + parentObject ?: YorkieLogger.e(TAG, "fail to find $parentCreatedAt") + YorkieLogger.e(TAG, "fail to execute, only Text, RichText can execute select") + emptyList() + } + } + + companion object { + private const val TAG = "SelectOperation" + } +} diff --git a/yorkie/src/main/kotlin/dev/yorkie/document/operation/TreeEditOperation.kt b/yorkie/src/main/kotlin/dev/yorkie/document/operation/TreeEditOperation.kt index f0af2f7ce..8835350d8 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/document/operation/TreeEditOperation.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/document/operation/TreeEditOperation.kt @@ -4,7 +4,6 @@ import dev.yorkie.document.crdt.CrdtRoot import dev.yorkie.document.crdt.CrdtTree import dev.yorkie.document.crdt.CrdtTreeNode import dev.yorkie.document.crdt.CrdtTreePos -import dev.yorkie.document.time.ActorID import dev.yorkie.document.time.TimeTicket import dev.yorkie.util.YorkieLogger @@ -15,7 +14,6 @@ internal data class TreeEditOperation( override val parentCreatedAt: TimeTicket, val fromPos: CrdtTreePos, val toPos: CrdtTreePos, - val maxCreatedAtMapByActor: Map, val contents: List?, override var executedAt: TimeTicket, ) : Operation() { @@ -32,14 +30,9 @@ internal data class TreeEditOperation( return emptyList() } val changes = - tree.edit( - fromPos to toPos, - contents?.map(CrdtTreeNode::deepCopy), - executedAt, - maxCreatedAtMapByActor, - ).first + tree.edit(fromPos to toPos, contents?.map(CrdtTreeNode::deepCopy), executedAt) - if (fromPos != toPos) { + if (fromPos.createdAt != toPos.createdAt || fromPos.offset != toPos.offset) { root.registerElementHasRemovedNodes(tree) } diff --git a/yorkie/src/main/kotlin/dev/yorkie/util/IndexTree.kt b/yorkie/src/main/kotlin/dev/yorkie/util/IndexTree.kt index 583fba1dc..b28b1c50a 100644 --- a/yorkie/src/main/kotlin/dev/yorkie/util/IndexTree.kt +++ b/yorkie/src/main/kotlin/dev/yorkie/util/IndexTree.kt @@ -67,7 +67,7 @@ internal class IndexTree>(val root: T) { fun nodesBetween( from: Int, to: Int, - action: (T, TagContained) -> Unit, + action: (T) -> Unit, ) { nodesBetweenInternal(root, from, to, action) } @@ -76,13 +76,12 @@ internal class IndexTree>(val root: T) { * Iterates the nodes between the given range. * If the given range is collapsed, the callback is not called. * It traverses the tree with postorder traversal. - * NOTE(sejongk): Nodes should not be removed in callback, because it leads to wrong behaviors. */ private fun nodesBetweenInternal( root: T, from: Int, to: Int, - action: ((T, TagContained) -> Unit), + action: ((T) -> Unit), ) { if (from > to) { throw IllegalArgumentException("from is greater than to: $from > $to") @@ -114,12 +113,7 @@ internal class IndexTree>(val root: T) { // If the range spans outside the child, // the callback is called with the child. if (fromChild < 0 || toChild > child.size || child.isText) { - val contained = when { - (fromChild < 0 && toChild > child.size) || child.isText -> TagContained.All - fromChild < 0 -> TagContained.Opening - else -> TagContained.Closing - } - action.invoke(child, contained) + action.invoke(child) } } pos += child.paddedSize @@ -145,7 +139,7 @@ internal class IndexTree>(val root: T) { val currentNode = node if (currentNode == null || currentNode == root) return@repeat - currentNode.split(offset, 0) + currentNode.split(offset) val nextOffset = currentNode.parent?.findOffset(currentNode) ?: return@repeat offset = if (offset == 0) nextOffset else nextOffset + 1 @@ -245,7 +239,7 @@ internal class IndexTree>(val root: T) { private fun addSizeOfLeftSiblings(parent: T, offset: Int): Int { return parent.children.take(offset).fold(0) { acc, leftSibling -> - acc + if (leftSibling.isRemoved) 0 else leftSibling.paddedSize + acc + leftSibling.paddedSize } } @@ -300,6 +294,18 @@ internal class IndexTree>(val root: T) { return TreePos(updatedNode, updatedPathElement) } + /** + * Finds right node of the given [treePos] with postorder traversal. + */ + fun findPostorderRight(treePos: TreePos): T? { + val (node, offset) = treePos + return when { + node.isText -> if (node.size == offset) node.nextSibling ?: node.parent else node + node.children.size == offset -> node + else -> findLeftMost(node.children[offset]) + } + } + private fun findLeftMost(node: T): T { return if (node.isText || node.children.isEmpty()) { node @@ -350,19 +356,12 @@ internal class IndexTree>(val root: T) { /** * Returns the path of the given [index]. */ - fun indexToPath(index: Int): List { + public fun indexToPath(index: Int): List { val treePos = findTreePos(index) return treePosToPath(treePos) } } -/** - * [TagContained] represents whether the opening or closing tag of a element is selected. - */ -internal enum class TagContained { - All, Opening, Closing, -} - /** * [TreePos] is the position of a node in the tree. * @@ -468,13 +467,6 @@ internal abstract class IndexTreeNode>(children: MutableLis check(!isText) { "Text node cannot have children" } - if (node.isRemoved) { - val index = _children.indexOf(node) - - // If nodes are removed, the offset of the removed node is the number of - // nodes before the node excluding the removed nodes. - return allChildren.take(index).filterNot { it.isRemoved }.size - } return children.indexOf(node) } @@ -510,10 +502,7 @@ internal abstract class IndexTreeNode>(children: MutableLis _children.addAll(newNode) newNode.forEach { node -> node.parent = this as T - - if (!node.isRemoved) { - node.updateAncestorSize() - } + node.updateAncestorSize() } } @@ -528,10 +517,7 @@ internal abstract class IndexTreeNode>(children: MutableLis _children.addAll(0, newNode.toList()) newNode.forEach { node -> node.parent = this as T - - if (!node.isRemoved) { - node.updateAncestorSize() - } + node.updateAncestorSize() } } @@ -604,24 +590,24 @@ internal abstract class IndexTreeNode>(children: MutableLis /** * Splits the node at the given [offset]. */ - fun split(offset: Int, absOffset: Int): T? { + fun split(offset: Int): T? { return if (isText) { - splitText(offset, absOffset) + splitText(offset) } else { splitElement(offset) } } - private fun splitText(offset: Int, absOffset: Int): T? { + private fun splitText(offset: Int): T? { if (offset == 0 || offset == size) { return null } val leftValue = value.substring(0, offset) - val rightValue = value.substring(offset).takeUnless { it.isEmpty() } ?: return null + val rightValue = value.substring(offset) value = leftValue - val rightNode = clone(offset + absOffset) + val rightNode = clone(offset) rightNode.value = rightValue parent?.insertAfterInternal(this as T, rightNode) diff --git a/yorkie/src/test/kotlin/dev/yorkie/api/ConverterTest.kt b/yorkie/src/test/kotlin/dev/yorkie/api/ConverterTest.kt index b91b6b109..206a8f7d5 100644 --- a/yorkie/src/test/kotlin/dev/yorkie/api/ConverterTest.kt +++ b/yorkie/src/test/kotlin/dev/yorkie/api/ConverterTest.kt @@ -18,7 +18,6 @@ import dev.yorkie.document.crdt.CrdtText import dev.yorkie.document.crdt.CrdtTree import dev.yorkie.document.crdt.CrdtTreeNode.Companion.CrdtTreeElement import dev.yorkie.document.crdt.CrdtTreeNode.Companion.CrdtTreeText -import dev.yorkie.document.crdt.CrdtTreeNodeID import dev.yorkie.document.crdt.CrdtTreePos import dev.yorkie.document.crdt.ElementRht import dev.yorkie.document.crdt.RgaTreeList @@ -32,15 +31,14 @@ import dev.yorkie.document.operation.MoveOperation import dev.yorkie.document.operation.Operation import dev.yorkie.document.operation.OperationInfo import dev.yorkie.document.operation.RemoveOperation +import dev.yorkie.document.operation.SelectOperation import dev.yorkie.document.operation.SetOperation import dev.yorkie.document.operation.StyleOperation import dev.yorkie.document.operation.TreeEditOperation import dev.yorkie.document.operation.TreeStyleOperation import dev.yorkie.document.time.ActorID -import dev.yorkie.document.time.ActorID.Companion.INITIAL_ACTOR_ID import dev.yorkie.document.time.TimeTicket import dev.yorkie.document.time.TimeTicket.Companion.InitialTimeTicket -import dev.yorkie.document.time.TimeTicket.Companion.MaxTimeTicket import dev.yorkie.util.IndexTreeNode.Companion.DEFAULT_ROOT_TYPE import org.junit.Assert.assertThrows import org.junit.Test @@ -52,7 +50,7 @@ class ConverterTest { @Test fun `should convert ByteString`() { - val actorID = INITIAL_ACTOR_ID + val actorID = ActorID.INITIAL_ACTOR_ID val converted = actorID.toByteString().toActorID() val maxActorID = ActorID.MAX_ACTOR_ID val maxConverted = maxActorID.toByteString().toActorID() @@ -234,6 +232,12 @@ class ConverterTest { InitialTimeTicket, mapOf("style" to "bold"), ) + val selectOperation = SelectOperation( + nodePos, + nodePos, + InitialTimeTicket, + InitialTimeTicket, + ) val styleOperation = StyleOperation( nodePos, nodePos, @@ -243,22 +247,15 @@ class ConverterTest { ) val treeEditOperation = TreeEditOperation( InitialTimeTicket, - CrdtTreePos(CrdtTreeNodeID(InitialTimeTicket, 5), CrdtTreeNodeID(InitialTimeTicket, 5)), - CrdtTreePos( - CrdtTreeNodeID(InitialTimeTicket, 10), - CrdtTreeNodeID(InitialTimeTicket, 10), - ), - mapOf(INITIAL_ACTOR_ID to MaxTimeTicket), - listOf(CrdtTreeText(CrdtTreeNodeID(InitialTimeTicket, 0), "hi")), + CrdtTreePos(InitialTimeTicket, 5), + CrdtTreePos(InitialTimeTicket, 10), + listOf(CrdtTreeText(CrdtTreePos(InitialTimeTicket, 0), "hi")), InitialTimeTicket, ) val treeStyleOperation = TreeStyleOperation( InitialTimeTicket, - CrdtTreePos(CrdtTreeNodeID(InitialTimeTicket, 5), CrdtTreeNodeID(InitialTimeTicket, 5)), - CrdtTreePos( - CrdtTreeNodeID(InitialTimeTicket, 10), - CrdtTreeNodeID(InitialTimeTicket, 10), - ), + CrdtTreePos(InitialTimeTicket, 5), + CrdtTreePos(InitialTimeTicket, 10), mapOf("a" to "b"), InitialTimeTicket, ) @@ -270,6 +267,7 @@ class ConverterTest { increaseOperation.toPBOperation(), editOperationWithoutAttrs.toPBOperation(), editOperationWithAttrs.toPBOperation(), + selectOperation.toPBOperation(), styleOperation.toPBOperation(), treeEditOperation.toPBOperation(), treeStyleOperation.toPBOperation(), @@ -282,9 +280,10 @@ class ConverterTest { assertEquals(increaseOperation, converted[4]) assertEquals(editOperationWithoutAttrs, converted[5]) assertEquals(editOperationWithAttrs, converted[6]) - assertEquals(styleOperation, converted[7]) - assertEquals(treeEditOperation, converted[8]) - assertEquals(treeStyleOperation, converted[9]) + assertEquals(selectOperation, converted[7]) + assertEquals(styleOperation, converted[8]) + assertEquals(treeEditOperation, converted[9]) + assertEquals(treeStyleOperation, converted[10]) } @Test @@ -392,7 +391,7 @@ class ConverterTest { val crdtCounter = CrdtCounter(1, InitialTimeTicket) val crdtText = CrdtText(RgaTreeSplit(), InitialTimeTicket) val crdtTree = CrdtTree( - CrdtTreeElement(CrdtTreeNodeID(InitialTimeTicket, 0), DEFAULT_ROOT_TYPE), + CrdtTreeElement(CrdtTreePos(InitialTimeTicket, 0), DEFAULT_ROOT_TYPE), InitialTimeTicket, ) val crdtElements = listOf( diff --git a/yorkie/src/test/kotlin/dev/yorkie/core/ClientTest.kt b/yorkie/src/test/kotlin/dev/yorkie/core/ClientTest.kt index 8e7bdbef1..2016995a4 100644 --- a/yorkie/src/test/kotlin/dev/yorkie/core/ClientTest.kt +++ b/yorkie/src/test/kotlin/dev/yorkie/core/ClientTest.kt @@ -1,6 +1,8 @@ package dev.yorkie.core +import com.google.protobuf.ByteString import dev.yorkie.api.PBChangePack +import dev.yorkie.api.toActorID import dev.yorkie.api.toChangePack import dev.yorkie.api.v1.ActivateClientRequest import dev.yorkie.api.v1.AttachDocumentRequest @@ -11,8 +13,8 @@ import dev.yorkie.api.v1.RemoveDocumentRequest import dev.yorkie.api.v1.WatchDocumentRequest import dev.yorkie.api.v1.YorkieServiceGrpcKt import dev.yorkie.assertJsonContentEquals -import dev.yorkie.core.Client.Event.DocumentChanged import dev.yorkie.core.Client.Event.DocumentSynced +import dev.yorkie.core.Client.Event.DocumentsChanged import dev.yorkie.core.MockYorkieService.Companion.ATTACH_ERROR_DOCUMENT_KEY import dev.yorkie.core.MockYorkieService.Companion.DETACH_ERROR_DOCUMENT_KEY import dev.yorkie.core.MockYorkieService.Companion.NORMAL_DOCUMENT_KEY @@ -26,7 +28,6 @@ import dev.yorkie.document.change.Change import dev.yorkie.document.change.ChangeID import dev.yorkie.document.change.ChangePack import dev.yorkie.document.change.CheckPoint -import dev.yorkie.document.time.ActorID import io.grpc.Channel import io.grpc.inprocess.InProcessChannelBuilder import io.grpc.inprocess.InProcessServerBuilder @@ -86,6 +87,7 @@ class ClientTest { assertTrue(target.isActive) val activatedStatus = assertIs(target.status.value) + assertEquals(TEST_KEY, activatedStatus.clientKey) assertEquals(TEST_ACTOR_ID, activatedStatus.clientId) val deactivateRequestCaptor = argumentCaptor() @@ -136,8 +138,8 @@ class ClientTest { val watchRequestCaptor = argumentCaptor() target.attachAsync(document).await() - val event = target.events.first { it is DocumentChanged } - val changeEvent = assertIs(event) + val event = target.events.first { it is DocumentsChanged } + val changeEvent = assertIs(event) verify(service, atLeastOnce()).watchDocument(watchRequestCaptor.capture()) assertIsTestActorID(watchRequestCaptor.firstValue.clientId) assertEquals(1, changeEvent.documentKeys.size) @@ -281,8 +283,8 @@ class ClientTest { } } - private fun assertIsTestActorID(clientId: String) { - assertEquals(TEST_ACTOR_ID, ActorID(clientId)) + private fun assertIsTestActorID(clientId: ByteString) { + assertEquals(TEST_ACTOR_ID, clientId.toActorID()) } private fun assertIsInitialChangePack(changePack: PBChangePack) { diff --git a/yorkie/src/test/kotlin/dev/yorkie/core/MockYorkieService.kt b/yorkie/src/test/kotlin/dev/yorkie/core/MockYorkieService.kt index 63f6f3979..e033f4b6c 100644 --- a/yorkie/src/test/kotlin/dev/yorkie/core/MockYorkieService.kt +++ b/yorkie/src/test/kotlin/dev/yorkie/core/MockYorkieService.kt @@ -2,6 +2,7 @@ package dev.yorkie.core import com.google.protobuf.kotlin.toByteString import dev.yorkie.api.PBTimeTicket +import dev.yorkie.api.toByteString import dev.yorkie.api.toPBChange import dev.yorkie.api.toPBTimeTicket import dev.yorkie.api.v1.ActivateClientRequest @@ -53,14 +54,17 @@ class MockYorkieService : YorkieServiceGrpcKt.YorkieServiceCoroutineImplBase() { override suspend fun activateClient(request: ActivateClientRequest): ActivateClientResponse { return activateClientResponse { - clientId = TEST_ACTOR_ID.value + clientId = TEST_ACTOR_ID.toByteString() + clientKey = request.clientKey } } override suspend fun deactivateClient( request: DeactivateClientRequest, ): DeactivateClientResponse { - return deactivateClientResponse { } + return deactivateClientResponse { + clientId = request.clientId + } } override suspend fun attachDocument(request: AttachDocumentRequest): AttachDocumentResponse { @@ -68,6 +72,7 @@ class MockYorkieService : YorkieServiceGrpcKt.YorkieServiceCoroutineImplBase() { throw StatusException(Status.UNKNOWN) } return attachDocumentResponse { + clientId = request.clientId changePack = changePack { documentKey = request.changePack.documentKey changes.add( @@ -93,7 +98,9 @@ class MockYorkieService : YorkieServiceGrpcKt.YorkieServiceCoroutineImplBase() { if (request.changePack.documentKey == DETACH_ERROR_DOCUMENT_KEY) { throw StatusException(Status.UNKNOWN) } - return detachDocumentResponse { } + return detachDocumentResponse { + clientKey = TEST_KEY + } } override suspend fun pushPullChanges(request: PushPullChangesRequest): PushPullChangesResponse { @@ -101,6 +108,7 @@ class MockYorkieService : YorkieServiceGrpcKt.YorkieServiceCoroutineImplBase() { throw StatusException(Status.UNAVAILABLE) } return pushPullChangesResponse { + clientId = request.clientId changePack = changePack { minSyncedTicket = InitialTimeTicket.toPBTimeTicket() changes.add( @@ -141,7 +149,7 @@ class MockYorkieService : YorkieServiceGrpcKt.YorkieServiceCoroutineImplBase() { emit( watchDocumentResponse { initialization = initialization { - clientIds.add(TEST_ACTOR_ID.value) + clientIds.add(TEST_ACTOR_ID.toByteString()) } }, ) @@ -152,7 +160,7 @@ class MockYorkieService : YorkieServiceGrpcKt.YorkieServiceCoroutineImplBase() { emit( watchDocumentResponse { event = docEvent { - type = DocEventType.DOC_EVENT_TYPE_DOCUMENT_CHANGED + type = DocEventType.DOC_EVENT_TYPE_DOCUMENTS_CHANGED publisher = request.clientId } }, @@ -161,7 +169,7 @@ class MockYorkieService : YorkieServiceGrpcKt.YorkieServiceCoroutineImplBase() { emit( watchDocumentResponse { event = docEvent { - type = DocEventType.DOC_EVENT_TYPE_DOCUMENT_WATCHED + type = DocEventType.DOC_EVENT_TYPE_DOCUMENTS_WATCHED publisher = request.clientId } }, @@ -170,7 +178,7 @@ class MockYorkieService : YorkieServiceGrpcKt.YorkieServiceCoroutineImplBase() { emit( watchDocumentResponse { event = docEvent { - type = DocEventType.DOC_EVENT_TYPE_DOCUMENT_UNWATCHED + type = DocEventType.DOC_EVENT_TYPE_DOCUMENTS_UNWATCHED publisher = request.clientId } }, @@ -183,6 +191,7 @@ class MockYorkieService : YorkieServiceGrpcKt.YorkieServiceCoroutineImplBase() { throw StatusException(Status.UNAVAILABLE) } return removeDocumentResponse { + clientKey = TEST_KEY changePack = changePack { minSyncedTicket = InitialTimeTicket.toPBTimeTicket() changes.add( diff --git a/yorkie/src/test/kotlin/dev/yorkie/document/crdt/CrdtTextTest.kt b/yorkie/src/test/kotlin/dev/yorkie/document/crdt/CrdtTextTest.kt index 302973b05..3ccc5926b 100644 --- a/yorkie/src/test/kotlin/dev/yorkie/document/crdt/CrdtTextTest.kt +++ b/yorkie/src/test/kotlin/dev/yorkie/document/crdt/CrdtTextTest.kt @@ -1,5 +1,6 @@ package dev.yorkie.document.crdt +import dev.yorkie.document.time.ActorID import dev.yorkie.document.time.TimeTicket import org.junit.Before import org.junit.Test @@ -45,4 +46,13 @@ class CrdtTextTest { target.toJson(), ) } + + @Test + fun `should handle select operations`() { + target.edit(target.indexRangeToPosRange(0, 0), "ABCD", TimeTicket.InitialTimeTicket) + val executedAt = TimeTicket(1L, 1u, ActorID.INITIAL_ACTOR_ID) + val textChange = target.select(target.indexRangeToPosRange(2, 4), executedAt) + assertEquals(TextChangeType.Selection, textChange?.type) + assertEquals(2 to 4, textChange?.from to textChange?.to) + } } diff --git a/yorkie/src/test/kotlin/dev/yorkie/document/crdt/CrdtTreeTest.kt b/yorkie/src/test/kotlin/dev/yorkie/document/crdt/CrdtTreeTest.kt index 9b1752734..53e2252f2 100644 --- a/yorkie/src/test/kotlin/dev/yorkie/document/crdt/CrdtTreeTest.kt +++ b/yorkie/src/test/kotlin/dev/yorkie/document/crdt/CrdtTreeTest.kt @@ -11,6 +11,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import dev.yorkie.document.crdt.CrdtTreePos.Companion.InitialCrdtTreePos as ITP class CrdtTreeTest { @@ -23,8 +24,8 @@ class CrdtTreeTest { @Test fun `should create CrdtTreeNode`() { - val node = CrdtTreeText(DIP, "hello") - assertEquals(DIP, node.id) + val node = CrdtTreeText(ITP, "hello") + assertEquals(ITP, node.pos) assertEquals(DEFAULT_TEXT_TYPE, node.type) assertEquals("hello", node.value) assertEquals(5, node.size) @@ -34,39 +35,42 @@ class CrdtTreeTest { @Test fun `should split CrdtTreeNode`() { - val para = CrdtTreeElement(DIP, "p") - para.append(CrdtTreeText(DIP, "helloyorkie")) + val para = CrdtTreeElement(ITP, "p") + para.append(CrdtTreeText(ITP, "helloyorkie")) assertEquals("

helloyorkie

", para.toXml()) assertEquals(11, para.size) assertFalse(para.isText) val left = para.children.first() - val right = left.split(5, 0) + val right = left.split(5) assertEquals(11, para.size) assertFalse(para.isText) assertEquals("hello", left.value) assertEquals("yorkie", right?.value) - assertEquals(CrdtTreeNodeID(TimeTicket.InitialTimeTicket, 0), left.id) - assertEquals(CrdtTreeNodeID(TimeTicket.InitialTimeTicket, 5), right?.id) + assertEquals(CrdtTreePos(TimeTicket.InitialTimeTicket, 0), left.pos) + assertEquals(CrdtTreePos(TimeTicket.InitialTimeTicket, 5), right?.pos) } @Test fun `should insert nodes with editByIndex`() { // 0 // - assertTrue(target.size == 0) + assertTrue(target.isEmpty()) assertEquals("", target.toXml()) + assertEquals(listOf("root"), target.toList()) // 1 //

target.editByIndex(0 to 0, CrdtTreeElement(issuePos(), "p").toList(), issueTime()) assertEquals("

", target.toXml()) + assertEquals(listOf("p", "root"), target.toList()) assertEquals(2, target.root.size) // 1 //

h e l l o

target.editByIndex(1 to 1, CrdtTreeText(issuePos(), "hello").toList(), issueTime()) assertEquals("

hello

", target.toXml()) + assertEquals(listOf("text.hello", "p", "root"), target.toList()) assertEquals(7, target.root.size) // 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 @@ -76,16 +80,25 @@ class CrdtTreeTest { } target.editByIndex(7 to 7, p.toList(), issueTime()) assertEquals("

hello

world

", target.toXml()) + assertEquals(listOf("text.hello", "p", "text.world", "p", "root"), target.toList()) assertEquals(14, target.root.size) // 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 //

h e l l o !

w o r l d

target.editByIndex(6 to 6, CrdtTreeText(issuePos(), "!").toList(), issueTime()) assertEquals("

hello!

world

", target.toXml()) + assertEquals( + listOf("text.hello", "text.!", "p", "text.world", "p", "root"), + target.toList(), + ) assertEquals(15, target.root.size) target.editByIndex(6 to 6, CrdtTreeText(issuePos(), "~").toList(), issueTime()) assertEquals("

hello~!

world

", target.toXml()) + assertEquals( + listOf("text.hello", "text.~", "text.!", "p", "text.world", "p", "root"), + target.toList(), + ) assertEquals(16, target.root.size) } @@ -99,19 +112,16 @@ class CrdtTreeTest { target.editByIndex(4 to 4, CrdtTreeElement(issuePos(), "p").toList(), issueTime()) target.editByIndex(5 to 5, CrdtTreeText(issuePos(), "cd").toList(), issueTime()) assertEquals("

ab

cd

", target.toXml()) + assertEquals(listOf("text.ab", "p", "text.cd", "p", "root"), target.toList()) assertEquals(8, target.root.size) // 02. delete b from first paragraph // 0 1 2 3 4 5 6 7 //

a

c d

- target.editByIndex(2 to 6, null, issueTime()) - // TODO(7hong13): should be resolved after the JS SDK implementation - // assertEquals("

ad

", target.toXml()) - - // 03. insert a new text node at the start of the first paragraph. - target.editByIndex(1 to 1, CrdtTreeText(issuePos(), "@").toList(), issueTime()) - // TODO(7hong13): should be resolved after the JS SDK implementation - // assertEquals("root>

@ad

", target.toXml()) + target.editByIndex(2 to 3, null, issueTime()) + assertEquals("

a

cd

", target.toXml()) + assertEquals(listOf("text.a", "p", "text.cd", "p", "root"), target.toList()) + assertEquals(7, target.root.size) } @Test @@ -124,117 +134,130 @@ class CrdtTreeTest { target.editByIndex(4 to 4, CrdtTreeElement(issuePos(), "p").toList(), issueTime()) target.editByIndex(5 to 5, CrdtTreeText(issuePos(), "cd").toList(), issueTime()) assertEquals("

ab

cd

", target.toXml()) + assertEquals(listOf("text.ab", "p", "text.cd", "p", "root"), target.toList()) - // 02. delete b from first paragraph - // 0 1 2 3 4 5 6 7 - //

a

c d

- target.editByIndex(2 to 3, null, issueTime()) - assertEquals("

a

cd

", target.toXml()) + // 02. delete b, c and first paragraph. + // 0 1 2 3 4 + //

a d

+ target.editByIndex(2 to 6, null, issueTime()) + assertEquals("

ad

", target.toXml()) + + // 03. insert a new text node at the start of the first paragraph. + target.editByIndex(1 to 1, CrdtTreeText(issuePos(), "@").toList(), issueTime()) + assertEquals("

@ad

", target.toXml()) } @Test - fun `should find the closest TreePos when parentNode or leftSiblingNode does not exist`() { - val pNode = CrdtTreeElement(issuePos(), "p") - val textNode = CrdtTreeText(issuePos(), "ab") + fun `should merge and edit different levels with editByIndex`() { + fun initializeTree() { + setUp() + target.editByIndex(0 to 0, CrdtTreeElement(issuePos(), "p").toList(), issueTime()) + target.editByIndex(1 to 1, CrdtTreeElement(issuePos(), "b").toList(), issueTime()) + target.editByIndex(2 to 2, CrdtTreeElement(issuePos(), "i").toList(), issueTime()) + target.editByIndex(3 to 3, CrdtTreeText(issuePos(), "ab").toList(), issueTime()) + assertEquals("

ab

", target.toXml()) + } - // 0 1 2 3 4 - //

a b

- target.editByIndex(0 to 0, pNode.toList(), issueTime()) - target.editByIndex(1 to 1, textNode.toList(), issueTime()) + // 01. edit between two element nodes in the same hierarchy. + // 0 1 2 3 4 5 6 7 8 + //

a b

+ initializeTree() + target.editByIndex(5 to 6, null, issueTime()) + assertEquals("

ab

", target.toXml()) + + // 02. edit between two element nodes in same hierarchy. + initializeTree() + target.editByIndex(6 to 7, null, issueTime()) + assertEquals("

ab

", target.toXml()) + + // 03. edit between text and element node in different hierarchy. + initializeTree() + target.editByIndex(4 to 6, null, issueTime()) + assertEquals("

a

", target.toXml()) + + // 04. edit between text and element node in different hierarchy. + initializeTree() + target.editByIndex(5 to 7, null, issueTime()) assertEquals("

ab

", target.toXml()) - // Find the closest index.TreePos when leftSiblingNode in crdt.TreePos is removed. - // 0 1 2 - //

- target.editByIndex(1 to 3, null, issueTime()) - assertEquals("

", target.toXml()) + // 05. edit between text and element node in different hierarchy. + initializeTree() + target.editByIndex(4 to 7, null, issueTime()) + assertEquals("

a

", target.toXml()) - var (parent, left) = target.findNodesAndSplitText( - CrdtTreePos(pNode.id, textNode.id), - issueTime(), - ) - assertEquals(1, target.toIndex(parent, left)) + // 06. edit between text and element node in different hierarchy. + initializeTree() + target.editByIndex(3 to 7, null, issueTime()) + assertEquals("

", target.toXml()) - // Find the closest index.TreePos when parentNode in crdt.TreePos is removed. - // 0 - // - target.editByIndex(0 to 2, null, issueTime()) - assertEquals("", target.toXml()) + // 07. edit between text and element node in same hierarchy. + setUp() + target.editByIndex(0 to 0, CrdtTreeElement(issuePos(), "p").toList(), issueTime()) + target.editByIndex(1 to 1, CrdtTreeText(issuePos(), "ab").toList(), issueTime()) + target.editByIndex(4 to 4, CrdtTreeElement(issuePos(), "p").toList(), issueTime()) + target.editByIndex(5 to 5, CrdtTreeElement(issuePos(), "b").toList(), issueTime()) + target.editByIndex(6 to 6, CrdtTreeText(issuePos(), "cd").toList(), issueTime()) + target.editByIndex(10 to 10, CrdtTreeElement(issuePos(), "p").toList(), issueTime()) + target.editByIndex(11 to 11, CrdtTreeText(issuePos(), "ef").toList(), issueTime()) + assertEquals("

ab

cd

ef

", target.toXml()) - target.findNodesAndSplitText(CrdtTreePos(pNode.id, textNode.id), issueTime()).also { - parent = it.first - left = it.second - } - assertEquals(0, target.toIndex(parent, left)) + target.editByIndex(9 to 10, null, issueTime()) + assertEquals("

ab

cd

ef

", target.toXml()) } @Test - fun `should merge and edit different levels with editByIndex`() { - // TODO(7hong13): should be resolved after the JS SDK implementation -// fun initializeTree() { -// setUp() -// target.editByIndex(0 to 0, CrdtTreeElement(issuePos(), "p").toList(), issueTime()) -// target.editByIndex(1 to 1, CrdtTreeElement(issuePos(), "b").toList(), issueTime()) -// target.editByIndex(2 to 2, CrdtTreeElement(issuePos(), "i").toList(), issueTime()) -// target.editByIndex(3 to 3, CrdtTreeText(issuePos(), "ab").toList(), issueTime()) -// assertEquals("

ab

", target.toXml()) -// } -// -// // 01. edit between two element nodes in the same hierarchy. -// // 0 1 2 3 4 5 6 7 8 -// //

a b

-// initializeTree() -// target.editByIndex(5 to 6, null, issueTime()) -// assertEquals("

ab

", target.toXml()) -// -// // 02. edit between two element nodes in same hierarchy. -// initializeTree() -// target.editByIndex(6 to 7, null, issueTime()) -// assertEquals("

ab

", target.toXml()) -// -// // 03. edit between text and element node in different hierarchy. -// initializeTree() -// target.editByIndex(4 to 6, null, issueTime()) -// assertEquals("

a

", target.toXml()) -// -// // 04. edit between text and element node in different hierarchy. -// initializeTree() -// target.editByIndex(5 to 7, null, issueTime()) -// assertEquals("

ab

", target.toXml()) -// -// // 05. edit between text and element node in different hierarchy. -// initializeTree() -// target.editByIndex(4 to 7, null, issueTime()) -// assertEquals("

a

", target.toXml()) -// -// // 06. edit between text and element node in different hierarchy. -// initializeTree() -// target.editByIndex(3 to 7, null, issueTime()) -// assertEquals("

", target.toXml()) -// -// // 07. edit between text and element node in same hierarchy. -// setUp() -// target.editByIndex(0 to 0, CrdtTreeElement(issuePos(), "p").toList(), issueTime()) -// target.editByIndex(1 to 1, CrdtTreeText(issuePos(), "ab").toList(), issueTime()) -// target.editByIndex(4 to 4, CrdtTreeElement(issuePos(), "p").toList(), issueTime()) -// target.editByIndex(5 to 5, CrdtTreeElement(issuePos(), "b").toList(), issueTime()) -// target.editByIndex(6 to 6, CrdtTreeText(issuePos(), "cd").toList(), issueTime()) -// target.editByIndex(10 to 10, CrdtTreeElement(issuePos(), "p").toList(), issueTime()) -// target.editByIndex(11 to 11, CrdtTreeText(issuePos(), "ef").toList(), issueTime()) -// assertEquals("

ab

cd

ef

", target.toXml()) -// -// target.editByIndex(9 to 10, null, issueTime()) -// assertEquals("

ab

cd

ef

", target.toXml()) + fun `should get correct index from CrdtTreePos`() { + // 0 1 2 3 4 5 6 7 8 + //

a b

+ target.editByIndex(0 to 0, CrdtTreeElement(issuePos(), "p").toList(), issueTime()) + target.editByIndex(1 to 1, CrdtTreeElement(issuePos(), "b").toList(), issueTime()) + target.editByIndex(2 to 2, CrdtTreeElement(issuePos(), "i").toList(), issueTime()) + target.editByIndex(3 to 3, CrdtTreeText(issuePos(), "ab").toList(), issueTime()) + assertEquals("

ab

", target.toXml()) + + var (from, to) = target.pathToPosRange(listOf(0)) + var fromIndex = target.toIndex(from) + var toIndex = target.toIndex(to) + assertEquals(7 to 8, fromIndex to toIndex) + + target.pathToPosRange(listOf(0, 0)).also { + from = it.first + to = it.second + } + fromIndex = target.toIndex(from) + toIndex = target.toIndex(to) + assertEquals(6 to 7, fromIndex to toIndex) + + target.pathToPosRange(listOf(0, 0, 0)).also { + from = it.first + to = it.second + } + fromIndex = target.toIndex(from) + toIndex = target.toIndex(to) + assertEquals(5 to 6, fromIndex to toIndex) + + var range = target.indexRangeToPosRange(0 to 5) + assertEquals(0 to 5, target.rangeToIndex(range)) + + range = target.indexRangeToPosRange(5 to 7) + assertEquals(5 to 7, target.rangeToIndex(range)) } - private fun issuePos(offset: Int = 0) = CrdtTreeNodeID(issueTime(), offset) + private fun issuePos(offset: Int = 0) = CrdtTreePos(issueTime(), offset) private fun issueTime() = DummyContext.issueTimeTicket() + private fun CrdtTree.toList() = map { node -> + if (node.isText) { + "${node.type}.${node.value}" + } else { + node.type + } + } + private fun CrdtTreeNode.toList() = listOf(this) companion object { - private val DIP = CrdtTreeNodeID(TimeTicket.InitialTimeTicket, 0) private val DummyContext = ChangeContext( ChangeID.InitialChangeID, diff --git a/yorkie/src/test/kotlin/dev/yorkie/document/json/JsonTextTest.kt b/yorkie/src/test/kotlin/dev/yorkie/document/json/JsonTextTest.kt index ff6236d1e..b7ab24662 100644 --- a/yorkie/src/test/kotlin/dev/yorkie/document/json/JsonTextTest.kt +++ b/yorkie/src/test/kotlin/dev/yorkie/document/json/JsonTextTest.kt @@ -136,6 +136,13 @@ class JsonTextTest { assertFalse(target.style(5, 7, emptyMap())) } + @Test + fun `should return false when index range is invalid with select`() { + assertTrue(target.select(0, 0)) + assertFalse(target.select(5, 0)) + assertFalse(target.select(5, 7)) + } + @Test fun `should handle text clear operations`() { target.edit(0, 0, "ABCD") diff --git a/yorkie/src/test/kotlin/dev/yorkie/document/json/JsonTreeTest.kt b/yorkie/src/test/kotlin/dev/yorkie/document/json/JsonTreeTest.kt index fef179425..a9bd5b7c1 100644 --- a/yorkie/src/test/kotlin/dev/yorkie/document/json/JsonTreeTest.kt +++ b/yorkie/src/test/kotlin/dev/yorkie/document/json/JsonTreeTest.kt @@ -9,7 +9,7 @@ import dev.yorkie.document.crdt.CrdtRoot import dev.yorkie.document.crdt.CrdtTree import dev.yorkie.document.crdt.CrdtTreeNode import dev.yorkie.document.crdt.CrdtTreeNode.Companion.CrdtTreeElement -import dev.yorkie.document.crdt.CrdtTreeNodeID +import dev.yorkie.document.crdt.CrdtTreePos import dev.yorkie.document.crdt.TreeNode import dev.yorkie.document.json.TreeBuilder.element import dev.yorkie.document.json.TreeBuilder.text @@ -95,6 +95,36 @@ class JsonTreeTest { target.toXml(), ) assertEquals(18, target.size) + assertContentEquals( + listOf( + text { "ab" }, + element("p") { + text { "ab" } + }, + text { "cd" }, + element("note") { + text { "cd" } + }, + text { "ef" }, + element("note") { + text { "ef" } + }, + element("ng") { + element("note") { + text { "cd" } + } + element("note") { + text { "ef" } + } + }, + text { "gh" }, + element("bp") { + text { "gh" } + }, + root, + ), + target.toList(), + ) } @Test @@ -225,19 +255,19 @@ class JsonTreeTest { target.toXml(), ) - target.style(1, 2, mapOf("c" to "d")) + target.style(4, 5, mapOf("c" to "d")) assertEquals( """

""", target.toXml(), ) - target.style(1, 2, mapOf("c" to "q")) + target.style(4, 5, mapOf("c" to "q")) assertEquals( """

""", target.toXml(), ) - target.style(2, 3, mapOf("z" to "m")) + target.style(3, 4, mapOf("z" to "m")) assertEquals( """

""", target.toXml(), @@ -307,7 +337,7 @@ class JsonTreeTest { root.tree().edit(1, 1, text { "X" }) assertEquals("

Xab

", root.tree().toXml()) - root.tree().style(0, 1, mapOf("a" to "b")) + root.tree().style(4, 5, mapOf("a" to "b")) }.await() assertContentEquals( listOf( @@ -319,12 +349,11 @@ class JsonTreeTest { listOf(TreeNode("text", value = "X")), "$.t", ), - // TODO(7hong13): need to check whether toPath is correctly passed TreeStyleOpInfo( - 0, - 1, + 4, + 5, + listOf(0), listOf(0), - listOf(0, 0), mapOf("a" to "b"), "$.t", ), @@ -386,12 +415,11 @@ class JsonTreeTest { listOf(TreeNode("text", value = "X")), "$.t", ), - // TODO(7hong13): need to check whether toPath is correctly passed TreeStyleOpInfo( - 2, - 3, + 6, + 7, + listOf(0, 0, 0), listOf(0, 0, 0), - listOf(0, 0, 0, 0), mapOf("a" to "b"), "$.t", ), @@ -432,286 +460,6 @@ class JsonTreeTest { assertEquals(5 to 7, tree.posRangeToIndexRange(posRange)) } - @Test - fun `should find pos range from path and vice versa`() = runTest { - val document = Document(Document.Key("")) - fun JsonObject.tree() = getAs("t") - - document.updateAsync { root, _ -> - root.setNewTree( - "t", - element("root") { - element("p") { - element("b") { - element("i") { - text { "ab" } - } - } - } - }, - ) - }.await() - assertEquals( - """

ab

""", - document.getRoot().tree().toXml(), - ) - - val tree = document.getRoot().tree() - var range = tree.pathRangeToPosRange(listOf(0) to listOf(0, 0, 0, 2)) - assertEquals(listOf(0) to listOf(0, 0, 0, 2), tree.posRangeToPathRange(range)) - - range = tree.pathRangeToPosRange(listOf(0) to listOf(1)) - assertEquals(listOf(0) to listOf(1), tree.posRangeToPathRange(range)) - } - - @Test - fun `should insert multiple text nodes`() = runTest { - val document = Document(Document.Key("")) - fun JsonObject.tree() = getAs("t") - - document.updateAsync { root, _ -> - root.setNewTree( - "t", - element("doc") { - element("p") { - text { "ab" } - } - }, - ) - }.await() - assertEquals("

ab

", document.getRoot().tree().toXml()) - - document.updateAsync { root, _ -> - root.tree().edit(3, 3, text { "c" }, text { "d" }) - }.await() - assertEquals("

abcd

", document.getRoot().tree().toXml()) - } - - @Test - fun `should insert multiple element nodes`() = runTest { - val document = Document(Document.Key("")) - fun JsonObject.tree() = getAs("t") - - document.updateAsync { root, _ -> - root.setNewTree( - "t", - element("doc") { - element("p") { - text { "ab" } - } - }, - ) - }.await() - assertEquals("

ab

", document.getRoot().tree().toXml()) - - document.updateAsync { root, _ -> - root.tree().edit( - 4, - 4, - element("p") { text { "cd" } }, - element("i") { text { "fg" } }, - ) - }.await() - assertEquals("

ab

cd

fg
", document.getRoot().tree().toXml()) - } - - @Suppress("ktlint:standard:max-line-length") - @Test - fun `should edit content with path when multi tree nodes passed`() = runTest { - val document = Document(Document.Key("")) - fun JsonObject.tree() = getAs("t") - - document.updateAsync { root, _ -> - root.setNewTree( - "t", - element("doc") { - element("tc") { - element("p") { - element("tn") { - text { "ab" } - } - } - } - }, - ) - assertEquals( - "

ab

", - document.getRoot().tree().toXml(), - ) - - root.tree().editByPath( - listOf(0, 0, 0, 1), - listOf(0, 0, 0, 1), - text { "X" }, - text { "X" }, - ) - assertEquals( - "

aXXb

", - document.getRoot().tree().toXml(), - ) - - root.tree().editByPath( - listOf(0, 1), - listOf(0, 1), - element("p") { - element("tn") { - text { "te" } - text { "st" } - } - }, - element("p") { - element("tn") { - text { "te" } - text { "xt" } - } - }, - ) - assertEquals( - "

aXXb

test

text

", - document.getRoot().tree().toXml(), - ) - - root.tree().editByPath( - listOf(0, 3), - listOf(0, 3), - element("p") { - element("tn") { - text { "te" } - text { "st" } - } - }, - element("tn") { - text { "te" } - text { "xt" } - }, - ) - assertEquals( - "

aXXb

test

text

test

text
", - document.getRoot().tree().toXml(), - ) - }.await() - } - - @Test - fun `should delete the first text with tombstone in front of target text`() = runTest { - val document = Document(Document.Key("")) - fun JsonObject.tree() = getAs("t") - - document.updateAsync { root, _ -> - root.setNewTree("t").edit( - 0, - 0, - element("p") { text { "abcdefghi" } }, - ) - assertEquals("

abcdefghi

", document.getRoot().tree().toXml()) - - root.tree().edit(1, 1, text { "12345" }) - assertEquals("

12345abcdefghi

", root.tree().toXml()) - - root.tree().edit(2, 5) - assertEquals("

15abcdefghi

", root.tree().toXml()) - - root.tree().edit(3, 5) - assertEquals("

15cdefghi

", root.tree().toXml()) - - root.tree().edit(2, 4) - assertEquals("

1defghi

", root.tree().toXml()) - - root.tree().edit(1, 3) - assertEquals("

efghi

", root.tree().toXml()) - - root.tree().edit(1, 2) - assertEquals("

fghi

", root.tree().toXml()) - - root.tree().edit(2, 5) - assertEquals("

f

", root.tree().toXml()) - - root.tree().edit(1, 2) - assertEquals("

", root.tree().toXml()) - }.await() - } - - @Test - fun `should delete node with a text node in front whose size is bigger than 1`() = runTest { - val document = Document(Document.Key("")) - fun JsonObject.tree() = getAs("t") - - document.updateAsync { root, _ -> - root.setNewTree("t").edit( - 0, - 0, - element("p") { text { "abcde" } }, - ) - assertEquals("

abcde

", document.getRoot().tree().toXml()) - - root.tree().edit(6, 6, text { "f" }) - assertEquals("

abcdef

", root.tree().toXml()) - - root.tree().edit(7, 7, text { "g" }) - assertEquals("

abcdefg

", root.tree().toXml()) - - root.tree().edit(7, 8) - assertEquals("<

abcdef

", root.tree().toXml()) - - root.tree().edit(6, 7) - assertEquals("<

abcde

", root.tree().toXml()) - - root.tree().edit(5, 6) - assertEquals("<

abcd

", root.tree().toXml()) - - root.tree().edit(4, 5) - assertEquals("<

abc

", root.tree().toXml()) - - root.tree().edit(3, 4) - assertEquals("<

ab

", root.tree().toXml()) - - root.tree().edit(2, 3) - assertEquals("<

a

", root.tree().toXml()) - - root.tree().edit(1, 2) - assertEquals("<

", root.tree().toXml()) - }.await() - } - - @Test - fun `should delete nodes correctly in a multi-level range`() = runTest { - val document = Document(Document.Key("")) - fun JsonObject.tree() = getAs("t") - - document.updateAsync { root, _ -> - root.setNewTree( - "t", - element("doc") { - element("p") { - text { "ab" } - element("p") { - text { "x" } - } - } - element("p") { - element("p") { - text { "cd" } - } - } - element("p") { - element("p") { - text { "y" } - } - text { "ef" } - } - }, - ) - assertEquals( - "

ab

x

cd

y

ef

", - document.getRoot().tree().toXml(), - ) - - root.tree().edit(2, 18) - // TODO(7hong13): should be resolved after implementing Tree.move() - // assertEquals("

a

af

", root.tree().toXml()) - }.await() - } - companion object { private val DummyContext = ChangeContext( ChangeID.InitialChangeID, @@ -722,7 +470,7 @@ class JsonTreeTest { get() = CrdtTree(rootCrdtTreeNode, InitialTimeTicket) private val rootCrdtTreeNode: CrdtTreeNode - get() = CrdtTreeElement(CrdtTreeNodeID(InitialTimeTicket, 0), DEFAULT_ROOT_TYPE) + get() = CrdtTreeElement(CrdtTreePos(InitialTimeTicket, 0), DEFAULT_ROOT_TYPE) private fun createTreeWithStyle(): JsonTree { val root = element("doc") { diff --git a/yorkie/src/test/kotlin/dev/yorkie/util/IndexTreeTest.kt b/yorkie/src/test/kotlin/dev/yorkie/util/IndexTreeTest.kt index a3b8936c5..8c60702a3 100644 --- a/yorkie/src/test/kotlin/dev/yorkie/util/IndexTreeTest.kt +++ b/yorkie/src/test/kotlin/dev/yorkie/util/IndexTreeTest.kt @@ -3,7 +3,7 @@ package dev.yorkie.util import dev.yorkie.document.crdt.CrdtTreeNode import dev.yorkie.document.crdt.CrdtTreeNode.Companion.CrdtTreeElement import dev.yorkie.document.crdt.CrdtTreeNode.Companion.CrdtTreeText -import dev.yorkie.document.crdt.CrdtTreeNodeID.Companion.InitialCrdtTreeNodeID +import dev.yorkie.document.crdt.CrdtTreePos.Companion.InitialCrdtTreePos import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Test @@ -48,6 +48,28 @@ class IndexTreeTest { } } + @Test + fun `should find right node from the given offset in postorder traversal`() { + // 0 1 2 3 4 5 6 7 8 + //

a b

c d

+ val tree = createIndexTree( + createElementNode( + "root", + createElementNode("p", createTextNode("ab")), + createElementNode("p", createTextNode("cd")), + ), + ) + + // postorder traversal: "ab", , "cd",

, + assertEquals("text", tree.findPostorderRight(tree.findTreePos(0))?.type) + assertEquals("text", tree.findPostorderRight(tree.findTreePos(1))?.type) + assertEquals("p", tree.findPostorderRight(tree.findTreePos(3))?.type) + assertEquals("text", tree.findPostorderRight(tree.findTreePos(4))?.type) + assertEquals("text", tree.findPostorderRight(tree.findTreePos(5))?.type) + assertEquals("p", tree.findPostorderRight(tree.findTreePos(7))?.type) + assertEquals("root", tree.findPostorderRight(tree.findTreePos(8))?.type) + } + @Test fun `should find common ancestor of two given nodes`() { val tree = createIndexTree( @@ -82,16 +104,12 @@ class IndexTreeTest { ), ) assertEquals( - listOf("text.b:All", "p:Closing", "text.cde:All", "p:All", "text.fg:All", "p:Opening"), + listOf("text.b", "p", "text.cde", "p", "text.fg", "p"), tree.nodesBetween(2, 11), ) - assertEquals( - listOf("text.b:All", "p:Closing", "text.cde:All", "p:Opening"), - tree.nodesBetween(2, 6), - ) - assertEquals(listOf("p:Opening"), tree.nodesBetween(0, 1)) - assertEquals(listOf("p:Closing"), tree.nodesBetween(3, 4)) - assertEquals(listOf("p:Closing", "p:Opening"), tree.nodesBetween(3, 5)) + assertEquals(listOf("p"), tree.nodesBetween(0, 1)) + assertEquals(listOf("p"), tree.nodesBetween(3, 4)) + assertEquals(listOf("p", "p"), tree.nodesBetween(3, 5)) } @Test @@ -380,18 +398,18 @@ class IndexTreeTest { } private fun IndexTree.nodesBetween(from: Int, to: Int) = buildList { - nodesBetween(from, to) { node, contain -> - add("${node.toDiagnostic()}:${contain.name}") + nodesBetween(from, to) { node -> + add(node.toDiagnostic()) } } companion object { private fun createElementNode(type: String, vararg childNode: CrdtTreeNode): CrdtTreeNode { - return CrdtTreeElement(InitialCrdtTreeNodeID, type, childNode.toList()) + return CrdtTreeElement(InitialCrdtTreePos, type, childNode.toList()) } private fun createTextNode(value: String) = - CrdtTreeText(InitialCrdtTreeNodeID, value) + CrdtTreeText(InitialCrdtTreePos, value) private val DefaultRootNode = createElementNode( "root",