Skip to content

Commit

Permalink
Add rootTreeNode for JsonTree (#135)
Browse files Browse the repository at this point in the history
* update github action

* fix operations to have path correctly

* add rootTreeNode for JsonTree

* remove unncessary string property

* fix tc failures

* fix scoping

* update keys

* sealed class -> sealed interface

* fix presence inconsistency

* adjust timing in TCs
  • Loading branch information
7hong13 authored Oct 17, 2023
1 parent 7583930 commit be1e634
Show file tree
Hide file tree
Showing 27 changed files with 156 additions and 166 deletions.
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ jobs:
- run: chmod +x gradlew
- uses: gradle/gradle-build-action@v2
- name: Setup Docker on macOS
uses: 7hong13/setup-docker-macos-action@fix_formula_paths
id: docker
uses: douglascamata/setup-docker-macos-action@fix-python-dep
- run: docker-compose -f docker/docker-compose-ci.yml up --build -d
- uses: actions/cache@v3
id: avd-cache
Expand Down
2 changes: 1 addition & 1 deletion docker/docker-compose-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ services:
depends_on:
- yorkie
yorkie:
image: 'yorkieteam/yorkie:0.4.6'
image: 'yorkieteam/yorkie:0.4.7'
container_name: 'yorkie'
command: [
'server',
Expand Down
2 changes: 1 addition & 1 deletion docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
yorkie:
image: 'yorkieteam/yorkie:0.4.6'
image: 'yorkieteam/yorkie:0.4.7'
container_name: 'yorkie'
command: [
'server',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import dev.yorkie.document.time.ActorID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.filterIsInstance
Expand All @@ -30,16 +31,18 @@ class EditorViewModel(private val client: Client) : ViewModel(), YorkieEditText.
private val _content = MutableSharedFlow<String>()
val content = _content.asSharedFlow()

private val _textOperationInfos =
MutableSharedFlow<Pair<ActorID, OperationInfo.TextOperationInfo>>()
val textOpInfos = _textOperationInfos.asSharedFlow()
private val _editOpInfos = MutableSharedFlow<OperationInfo.EditOpInfo>()
val editOpInfos = _editOpInfos.asSharedFlow()

private val _selections = MutableSharedFlow<Selection>()
val selections = _selections.asSharedFlow()

val removedPeers = document.events.filterIsInstance<PresenceChange.Others.Unwatched>()
.map { it.unwatched.actorID }
.map { it.changed.actorID }

private val _peerSelectionInfos = mutableMapOf<ActorID, PeerSelectionInfo>()
val peerSelectionInfos: Map<ActorID, PeerSelectionInfo>
get() = _peerSelectionInfos
private val _selectionColors = mutableMapOf<ActorID, Int>()
val selectionColors: Map<ActorID, Int>
get() = _selectionColors

private val gson = Gson()

Expand All @@ -63,39 +66,40 @@ class EditorViewModel(private val client: Client) : ViewModel(), YorkieEditText.

is Document.Event.RemoteChange -> emitEditOpInfos(event.changeInfo)

is PresenceChange.Others.PresenceChanged -> event.changed.emitSelectOpInfo()
is PresenceChange.Others.PresenceChanged -> event.changed.emitSelection()

else -> {}
}
}
}
}

private suspend fun emitEditOpInfos(changeInfo: Document.Event.ChangeInfo) {
changeInfo.operations.filterIsInstance<OperationInfo.EditOpInfo>()
.forEach { opInfo ->
_textOperationInfos.emit(changeInfo.actorID to opInfo)
}
}

private suspend fun PresenceInfo.emitSelectOpInfo() {
val jsonArray = JSONArray(presence["selection"] ?: return)
val fromPos =
gson.fromJson(jsonArray.getString(0), TextPosStructure::class.java) ?: return
val toPos =
gson.fromJson(jsonArray.getString(1), TextPosStructure::class.java) ?: return
val (from, to) = document.getRoot().getAs<JsonText>(TEXT_KEY)
.posRangeToIndexRange(fromPos to toPos)
_textOperationInfos.emit(actorID to OperationInfo.SelectOpInfo(from, to))
}

fun syncText() {
viewModelScope.launch {
val content = document.getRoot().getAsOrNull<JsonText>(TEXT_KEY)
_content.emit(content?.toString().orEmpty())
}
}

private suspend fun emitEditOpInfos(changeInfo: Document.Event.ChangeInfo) {
changeInfo.operations.filterIsInstance<OperationInfo.EditOpInfo>()
.forEach { _editOpInfos.emit(it) }
}

private suspend fun PresenceInfo.emitSelection() {
runCatching {
delay(500)
val jsonArray = JSONArray(presence["selection"] ?: return)
val fromPos =
gson.fromJson(jsonArray.getString(0), TextPosStructure::class.java) ?: return
val toPos =
gson.fromJson(jsonArray.getString(1), TextPosStructure::class.java) ?: return
val (from, to) = document.getRoot().getAs<JsonText>(TEXT_KEY)
.posRangeToIndexRange(fromPos to toPos)
_selections.emit(Selection(actorID, from, to))
}
}

override fun handleEditEvent(from: Int, to: Int, content: CharSequence) {
viewModelScope.launch {
document.updateAsync { root, _ ->
Expand All @@ -119,21 +123,13 @@ class EditorViewModel(private val client: Client) : ViewModel(), YorkieEditText.

@ColorInt
fun getPeerSelectionColor(actorID: ActorID): Int {
return _peerSelectionInfos[actorID]?.color ?: run {
val newColor =
Color.argb(51, Random.nextInt(256), Random.nextInt(256), Random.nextInt(256))
_peerSelectionInfos[actorID] = PeerSelectionInfo(newColor)
newColor
return _selectionColors.getOrPut(actorID) {
Color.argb(51, Random.nextInt(256), Random.nextInt(256), Random.nextInt(256))
}
}

fun updatePeerPrevSelection(actorID: ActorID, prevSelection: Pair<Int, Int>?) {
val peerSelectionInfo = _peerSelectionInfos[actorID] ?: return
_peerSelectionInfos[actorID] = peerSelectionInfo.copy(prevSelection = prevSelection)
}

fun removeDetachedPeerSelectionInfo(actorID: ActorID) {
_peerSelectionInfos.remove(actorID)
fun removeUnwatchedPeerSelectionInfo(actorID: ActorID) {
_selectionColors.remove(actorID)
}

override fun handleHangulCompositionStart() {
Expand All @@ -152,10 +148,7 @@ class EditorViewModel(private val client: Client) : ViewModel(), YorkieEditText.
super.onCleared()
}

data class PeerSelectionInfo(
@ColorInt val color: Int,
val prevSelection: Pair<Int, Int>? = null,
)
data class Selection(val clientID: ActorID, val from: Int, val to: Int)

companion object {
private const val DOCUMENT_KEY = "document-key"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.core.text.getSpans
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.texteditor.EditorViewModel.Selection
import com.example.texteditor.databinding.ActivityMainBinding
import dev.yorkie.core.Client
import dev.yorkie.document.operation.OperationInfo
Expand Down Expand Up @@ -51,18 +52,21 @@ class MainActivity : AppCompatActivity() {
}

launch {
viewModel.textOpInfos.collect { (actor, opInfo) ->
when (opInfo) {
is OperationInfo.EditOpInfo -> opInfo.handleContentChange()
is OperationInfo.SelectOpInfo -> opInfo.handleSelectChange(actor)
}
viewModel.editOpInfos.collect { opInfo ->
opInfo.handleContentChange()
}
}

launch {
viewModel.removedPeers.collect {
binding.textEditor.text?.removePrevSpan(it)
viewModel.removeDetachedPeerSelectionInfo(it)
viewModel.removeUnwatchedPeerSelectionInfo(it)
}
}

launch {
viewModel.selections.collect { selection ->
selection.handleSelectChange()
}
}
}
Expand All @@ -82,26 +86,21 @@ class MainActivity : AppCompatActivity() {
}
}

private fun OperationInfo.SelectOpInfo.handleSelectChange(actor: ActorID) {
private fun Selection.handleSelectChange() {
val editable = binding.textEditor.text ?: return

if (editable.removePrevSpan(actor) && from == to) {
viewModel.updatePeerPrevSelection(actor, null)
} else {
editable.setSpan(
BackgroundColorSpan(viewModel.getPeerSelectionColor(actor)),
from.coerceAtMost(to),
to.coerceAtLeast(from),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
)
viewModel.updatePeerPrevSelection(actor, from to to)
}
editable.removePrevSpan(clientID)
editable.setSpan(
BackgroundColorSpan(viewModel.getPeerSelectionColor(clientID)),
from.coerceAtMost(to),
to.coerceAtLeast(from),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
)
}

private fun Editable.removePrevSpan(actorID: ActorID): Boolean {
val (start, end) = viewModel.peerSelectionInfos[actorID]?.prevSelection ?: return false
val backgroundSpan = getSpans<BackgroundColorSpan>(start, end).firstOrNull {
it.backgroundColor == viewModel.peerSelectionInfos[actorID]?.color
val backgroundSpan = getSpans<BackgroundColorSpan>(0, length).firstOrNull {
it.backgroundColor == viewModel.selectionColors[actorID]
}
backgroundSpan?.let(::removeSpan)
return true
Expand Down
8 changes: 7 additions & 1 deletion yorkie/src/androidTest/kotlin/dev/yorkie/core/ClientTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,9 @@ class ClientTest {
document1.updateAsync { root, _ ->
root["version"] = "v1"
}.await()
assertNotEquals(document1.toJson(), document2.toJson())
assertJsonContentEquals("""{"version":"v1"}""", document1.toJson())
assertJsonContentEquals("""{}""", document2.toJson())

client1.syncAsync().await()
client2.syncAsync().await()
assertEquals(document1.toJson(), document2.toJson())
Expand All @@ -190,11 +192,14 @@ class ClientTest {
val collectJob = launch(start = CoroutineStart.UNDISPATCHED) {
client2.events.collect(client2Events::add)
}

client2.resume(document2)
delay(30)
document1.updateAsync { root, _ ->
root["version"] = "v2"
}.await()
client1.syncAsync().await()

withTimeout(GENERAL_TIMEOUT) {
while (client2Events.size < 2) {
delay(50)
Expand All @@ -210,6 +215,7 @@ class ClientTest {
root["version"] = "v3"
}.await()
assertNotEquals(document1.toJson(), document2.toJson())

client1.syncAsync().await()
client2.syncAsync().await()
assertEquals(document1.toJson(), document2.toJson())
Expand Down
7 changes: 4 additions & 3 deletions yorkie/src/androidTest/kotlin/dev/yorkie/core/DocumentTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import org.junit.runner.RunWith
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue

@RunWith(AndroidJUnit4::class)
class DocumentTest {
Expand Down Expand Up @@ -167,8 +166,10 @@ class DocumentTest {
client2.syncAsync().await()
assertEquals(document1.toJson(), document2.toJson())

assertTrue(client1.removeAsync(document1).await())
assertTrue(client2.removeAsync(document2).await())
client1.removeAsync(document1).await()
if (document2.status == DocumentStatus.Attached) {
client2.removeAsync(document2).await()
}
assertEquals(DocumentStatus.Removed, document1.status)
assertEquals(DocumentStatus.Removed, document2.status)
}
Expand Down
24 changes: 14 additions & 10 deletions yorkie/src/androidTest/kotlin/dev/yorkie/core/PresenceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
Expand Down Expand Up @@ -235,15 +236,14 @@ class PresenceTest {
.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<Document.Event.PresenceChange>()
.filterNot { it is MyPresence.Initialized }
.collect(d2Events::add)
}

withTimeout(GENERAL_TIMEOUT + 1) {
withTimeout(GENERAL_TIMEOUT) {
while (d1Events.isEmpty()) {
delay(50)
}
Expand All @@ -260,7 +260,7 @@ class PresenceTest {
presence.put(mapOf("name" to "X"))
}.await()

withTimeout(GENERAL_TIMEOUT + 2) {
withTimeout(GENERAL_TIMEOUT) {
while (d1Events.size < 2 || d2Events.isEmpty()) {
delay(50)
}
Expand Down Expand Up @@ -372,15 +372,14 @@ class PresenceTest {
c1.attachAsync(d1, initialPresence = mapOf("name" to "a1", "cursor" to cursor)).await()

val d1CollectJob = launch(start = CoroutineStart.UNDISPATCHED) {
d1.events.filterIsInstance<Document.Event.PresenceChange>()
.collect(d1Events::add)
d1.events.filterIsInstance<Others>().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) {
withTimeout(GENERAL_TIMEOUT + 1) {
// c2 watched
while (d1Events.isEmpty()) {
delay(50)
Expand All @@ -397,7 +396,7 @@ class PresenceTest {
// 02. c2 pauses the document (in manual sync), c3 resumes the document (in realtime sync).
c2.pause(d2)

withTimeout(GENERAL_TIMEOUT) {
withTimeout(GENERAL_TIMEOUT + 2) {
// c2 unwatched
while (d1Events.size < 2) {
delay(50)
Expand All @@ -407,14 +406,17 @@ class PresenceTest {
assertIs<Others.Unwatched>(d1Events.last())
c3.resume(d3)

withTimeout(GENERAL_TIMEOUT) {
withTimeout(GENERAL_TIMEOUT + 3) {
// c3 watched
while (d1Events.size < 3) {
delay(50)
}
}

assertIs<Others.Watched>(d1Events.last())
withTimeout(GENERAL_TIMEOUT) {
d3.presences.first { c3ID in d3.presences.value }
}
assertEquals(
mapOf(c1ID to d1.presences.value[c1ID], c3ID to d3.presences.value[c3ID]),
d1.presences.value.toMap(),
Expand Down Expand Up @@ -455,7 +457,7 @@ class PresenceTest {
.await()

val d1CollectJob = launch(start = CoroutineStart.UNDISPATCHED) {
d1.events.filterIsInstance<Document.Event.PresenceChange>()
d1.events.filterIsInstance<Others>()
.collect(d1Events::add)
}

Expand Down Expand Up @@ -529,6 +531,7 @@ class PresenceTest {
// from c3, only the watched event is triggered.
c3.syncAsync().await()
c1.syncAsync().await()
delay(50)
c3.resume(d3)

withTimeout(GENERAL_TIMEOUT) {
Expand Down Expand Up @@ -591,10 +594,11 @@ class PresenceTest {
// 05-2. c2 resumes the document, c1 receives a watched event from c2.
c2.syncAsync().await()
c1.syncAsync().await()
delay(50)
c2.resume(d2)

withTimeout(GENERAL_TIMEOUT) {
// c3 unwatched
// c2 watched
while (d1Events.size < 7) {
delay(50)
}
Expand Down
Loading

0 comments on commit be1e634

Please sign in to comment.