diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 94908cc..1af85ee 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -178,14 +178,14 @@ jobs: env: JAVA_HOME: ${{ env.JAVA_HOME_FOR_BUILD }} with: - arguments: lib:assemble websocket:assemble -Penv=dev --info + arguments: lib:assemble websocket:assemble automerge-kotlin:assemble automerge-kotlin:dokkaGeneratePublicationHtml -Penv=dev --info - name: Test with Java 21 runtime uses: gradle/gradle-build-action@749f47bda3e44aa060e82d7b3ef7e40d953bd629 env: JAVA_HOME: ${{ env.JAVA_HOME_FOR_BUILD }} with: - arguments: lib:test websocket:test -Penv=dev --info + arguments: lib:test websocket:test automerge-kotlin:test -Penv=dev --info - name: Test with Java 8 runtime (backward compatibility) uses: gradle/gradle-build-action@749f47bda3e44aa060e82d7b3ef7e40d953bd629 diff --git a/automerge-kotlin/.gitignore b/automerge-kotlin/.gitignore new file mode 100644 index 0000000..73cceac --- /dev/null +++ b/automerge-kotlin/.gitignore @@ -0,0 +1,2 @@ +bin +.classpath diff --git a/automerge-kotlin/build.gradle.kts b/automerge-kotlin/build.gradle.kts new file mode 100644 index 0000000..c0744f7 --- /dev/null +++ b/automerge-kotlin/build.gradle.kts @@ -0,0 +1,89 @@ +plugins { + kotlin("jvm") + id("org.danilopianini.publish-on-central") + id("org.jetbrains.dokka") +} + +// Use in-process compilation to avoid Kotlin daemon filesystem issues +tasks.withType { + compilerExecutionStrategy.set(org.jetbrains.kotlin.gradle.tasks.KotlinCompilerExecutionStrategy.IN_PROCESS) +} + +java { + withJavadocJar() + withSourcesJar() + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(project(":lib")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + + testImplementation(platform("org.junit:junit-bom:5.11.4")) + testImplementation("org.junit.jupiter:junit-jupiter-api") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("org.slf4j:slf4j-simple:2.0.9") +} + +publishOnCentral { + projectDescription.set("Kotlin extensions for Automerge") + projectLongName.set("Automerge Kotlin") +} + +publishing { + publications { + withType { + artifactId = "automerge-kotlin" + } + } +} + +val env = providers.gradleProperty("env").getOrElse("release") +val isDev = env == "dev" + +if (isDev) { + tasks.register("compileRustForTest") { + workingDir = File("../rust") + commandLine = listOf("cargo", "build") + } + + val version = (project.extra.get("libVersionSuffix") as String) + + tasks.register("createVersionedLibForTest") { + dependsOn("compileRustForTest") + val debugDir = file("../rust/target/debug") + doLast { + listOf("libautomerge_jni" to "so", "libautomerge_jni" to "dylib", "automerge_jni" to "dll").forEach { (base, ext) -> + val src = debugDir.resolve("$base.$ext") + if (src.exists()) { + src.copyTo(debugDir.resolve("${base}_$version.$ext"), overwrite = true) + } + } + } + } + + tasks.withType { + dependsOn("createVersionedLibForTest") + systemProperty("java.library.path", file("../rust/target/debug").absolutePath) + } +} + +tasks.test { + useJUnitPlatform() +} + +dokka { + moduleName.set("Automerge Kotlin") + // Fail the doc build on unresolved references or other warnings so CI + // catches broken KDoc before release. + dokkaPublications.configureEach { + failOnWarning.set(true) + } +} diff --git a/automerge-kotlin/src/main/kotlin/org/automerge/kotlin/DocHandleExtensions.kt b/automerge-kotlin/src/main/kotlin/org/automerge/kotlin/DocHandleExtensions.kt new file mode 100644 index 0000000..aa9601e --- /dev/null +++ b/automerge-kotlin/src/main/kotlin/org/automerge/kotlin/DocHandleExtensions.kt @@ -0,0 +1,140 @@ +package org.automerge.kotlin + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.future.await +import org.automerge.AmValue +import org.automerge.Document +import org.automerge.MapEntry +import org.automerge.ObjectId +import org.automerge.repo.DocHandle + +// ── Core observe ──────────────────────────────────────────────────── + +/** + * Observe a document query as a [StateFlow]. + * + * This suspend function: + * 1. Evaluates [query] once via `handle.withDocument(query)` to get the initial value. + * 2. Collects [changeFlow] and re-evaluates [query] on each change. + * 3. Uses [distinctUntilChanged] to suppress duplicate emissions. + * 4. Returns a [StateFlow] scoped to [scope]. + * + * **Error handling:** If a query re-evaluation throws, the StateFlow retains its + * previous value (the error is swallowed). This prevents a single bad read from + * terminating the observation. + * + * @param scope The [CoroutineScope] that controls the lifetime of the observation. + * @param query A function that reads from a [Document] and returns a value of type [T]. + * @return A [StateFlow] of the query result. + */ +suspend fun DocHandle.observe( + scope: CoroutineScope, + query: (Document) -> T, +): StateFlow { + val initial = withDocument(query).await() + @Suppress("UNCHECKED_CAST") + return changeFlow() + .map { + try { + withDocument(query).await() + } catch (_: Exception) { + initial // On error, keep previous/initial value + } + } + .distinctUntilChanged() + .stateIn(scope, SharingStarted.Eagerly, initial) +} + +/** + * Observe a document query as a [StateFlow] with an explicit initial value. + * + * This non-suspend variant returns immediately. The first real query evaluation + * is launched asynchronously in [scope]. Useful in `ViewModel.init {}` where + * you want to assign a `val` without launching a coroutine. + * + * @param scope The [CoroutineScope] that controls the lifetime of the observation. + * @param initialValue The value the StateFlow starts with before the first query. + * @param query A function that reads from a [Document] and returns a value of type [T]. + * @return A [StateFlow] of the query result. + */ +fun DocHandle.observe( + scope: CoroutineScope, + initialValue: T, + query: (Document) -> T, +): StateFlow { + val state = kotlinx.coroutines.flow.MutableStateFlow(initialValue) + + // Launch a coroutine that: + // 1. Evaluates the query immediately to get the current state + // 2. Then collects changeFlow and re-evaluates on each change + scope.launch { + // Initial evaluation + try { + state.value = withDocument(query).await() + } catch (_: Exception) { + // Keep initialValue on error + } + // Ongoing evaluation on changes + changeFlow().collect { + try { + val newValue = withDocument(query).await() + state.value = newValue + } catch (_: Exception) { + // Keep previous value on error + } + } + } + + return state +} + +// ── Convenience extensions ────────────────────────────────────────── + +/** Observe a String property at [key] in map [obj]. */ +fun DocHandle.observeString( + scope: CoroutineScope, obj: ObjectId, key: String, +): StateFlow = observe(scope, null) { doc -> doc.getString(obj, key) } + +/** Observe a Long property at [key] in map [obj]. */ +fun DocHandle.observeLong( + scope: CoroutineScope, obj: ObjectId, key: String, +): StateFlow = observe(scope, null) { doc -> doc.getLong(obj, key) } + +/** Observe a Double property at [key] in map [obj]. */ +fun DocHandle.observeDouble( + scope: CoroutineScope, obj: ObjectId, key: String, +): StateFlow = observe(scope, null) { doc -> doc.getDouble(obj, key) } + +/** Observe a Boolean property at [key] in map [obj]. */ +fun DocHandle.observeBoolean( + scope: CoroutineScope, obj: ObjectId, key: String, +): StateFlow = observe(scope, null) { doc -> doc.getBoolean(obj, key) } + +/** Observe the text content of a Text object. */ +fun DocHandle.observeText( + scope: CoroutineScope, obj: ObjectId, +): StateFlow = observe(scope, null) { doc -> doc.text(obj).orElse(null) } + +/** Observe the entries of a Map object. */ +fun DocHandle.observeMapEntries( + scope: CoroutineScope, obj: ObjectId, +): StateFlow> = observe(scope, emptyList()) { doc -> + doc.mapEntries(obj).orElse(null)?.toList() ?: emptyList() +} + +/** Observe the items of a List object. */ +fun DocHandle.observeListItems( + scope: CoroutineScope, obj: ObjectId, +): StateFlow> = observe(scope, emptyList()) { doc -> + doc.listItems(obj).orElse(null)?.toList() ?: emptyList() +} + +/** Suspend-friendly wrapper around [DocHandle.withDocument]. */ +suspend fun DocHandle.withDocAsync(mutation: (Document) -> T): T = + withDocument(mutation).await() diff --git a/automerge-kotlin/src/main/kotlin/org/automerge/kotlin/DocHandleFlow.kt b/automerge-kotlin/src/main/kotlin/org/automerge/kotlin/DocHandleFlow.kt new file mode 100644 index 0000000..03f9053 --- /dev/null +++ b/automerge-kotlin/src/main/kotlin/org/automerge/kotlin/DocHandleFlow.kt @@ -0,0 +1,22 @@ +package org.automerge.kotlin + +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import org.automerge.repo.DocHandle +import org.automerge.repo.DocumentChanged + +/** + * Creates a [Flow] that emits [DocumentChanged] events whenever this document changes. + * + * The flow is backed by a change listener registered on this [DocHandle]. + * The listener is automatically removed when the flow collector is cancelled. + * + * Events fire for any change reason: local mutation, remote sync, or merge. + */ +fun DocHandle.changeFlow(): Flow = callbackFlow { + val registration = addChangeListener { event -> + trySend(event) + } + awaitClose { registration.remove() } +} diff --git a/automerge-kotlin/src/main/kotlin/org/automerge/kotlin/ReadExtensions.kt b/automerge-kotlin/src/main/kotlin/org/automerge/kotlin/ReadExtensions.kt new file mode 100644 index 0000000..dc43636 --- /dev/null +++ b/automerge-kotlin/src/main/kotlin/org/automerge/kotlin/ReadExtensions.kt @@ -0,0 +1,84 @@ +package org.automerge.kotlin + +import org.automerge.AmValue +import org.automerge.ObjectId +import org.automerge.Read +import java.util.Date + +// ── Scalar reads from maps ────────────────────────────────────────── + +/** Get a String value from a map, or null if absent or wrong type. */ +fun Read.getString(obj: ObjectId, key: String): String? = + get(obj, key).orElse(null)?.let { (it as? AmValue.Str)?.value } + +/** Get a Long value from a map (Int or UInt), or null if absent or wrong type. */ +fun Read.getLong(obj: ObjectId, key: String): Long? = + get(obj, key).orElse(null)?.let { + when (it) { + is AmValue.Int -> it.value + is AmValue.UInt -> it.value + else -> null + } + } + +/** Get a Double value from a map, or null if absent or wrong type. */ +fun Read.getDouble(obj: ObjectId, key: String): Double? = + get(obj, key).orElse(null)?.let { (it as? AmValue.F64)?.value } + +/** Get a Boolean value from a map, or null if absent or wrong type. */ +fun Read.getBoolean(obj: ObjectId, key: String): Boolean? = + get(obj, key).orElse(null)?.let { (it as? AmValue.Bool)?.value } + +/** Get a ByteArray value from a map, or null if absent or wrong type. */ +fun Read.getBytes(obj: ObjectId, key: String): ByteArray? = + get(obj, key).orElse(null)?.let { (it as? AmValue.Bytes)?.value } + +/** Get a Counter value from a map as Long, or null if absent or wrong type. */ +fun Read.getCounter(obj: ObjectId, key: String): Long? = + get(obj, key).orElse(null)?.let { (it as? AmValue.Counter)?.value } + +/** Get a Timestamp value from a map, or null if absent or wrong type. */ +fun Read.getTimestamp(obj: ObjectId, key: String): Date? = + get(obj, key).orElse(null)?.let { (it as? AmValue.Timestamp)?.value } + +// ── Scalar reads from lists ───────────────────────────────────────── + +/** Get a String value from a list by index, or null if absent or wrong type. */ +fun Read.getString(obj: ObjectId, idx: Long): String? = + get(obj, idx).orElse(null)?.let { (it as? AmValue.Str)?.value } + +/** Get a Long value from a list by index (Int or UInt), or null if absent or wrong type. */ +fun Read.getLong(obj: ObjectId, idx: Long): Long? = + get(obj, idx).orElse(null)?.let { + when (it) { + is AmValue.Int -> it.value + is AmValue.UInt -> it.value + else -> null + } + } + +/** Get a Double value from a list by index, or null if absent or wrong type. */ +fun Read.getDouble(obj: ObjectId, idx: Long): Double? = + get(obj, idx).orElse(null)?.let { (it as? AmValue.F64)?.value } + +/** Get a Boolean value from a list by index, or null if absent or wrong type. */ +fun Read.getBoolean(obj: ObjectId, idx: Long): Boolean? = + get(obj, idx).orElse(null)?.let { (it as? AmValue.Bool)?.value } + +/** Get a ByteArray value from a list by index, or null if absent or wrong type. */ +fun Read.getBytes(obj: ObjectId, idx: Long): ByteArray? = + get(obj, idx).orElse(null)?.let { (it as? AmValue.Bytes)?.value } + +// ── Object reads (return ObjectId of nested map/list/text) ────────── + +/** Get the ObjectId of a nested Map from a map key, or null if absent or wrong type. */ +fun Read.getMapId(obj: ObjectId, key: String): ObjectId? = + get(obj, key).orElse(null)?.let { (it as? AmValue.Map)?.id } + +/** Get the ObjectId of a nested List from a map key, or null if absent or wrong type. */ +fun Read.getListId(obj: ObjectId, key: String): ObjectId? = + get(obj, key).orElse(null)?.let { (it as? AmValue.List)?.id } + +/** Get the ObjectId of a nested Text from a map key, or null if absent or wrong type. */ +fun Read.getTextId(obj: ObjectId, key: String): ObjectId? = + get(obj, key).orElse(null)?.let { (it as? AmValue.Text)?.id } diff --git a/automerge-kotlin/src/main/kotlin/org/automerge/kotlin/RepoExtensions.kt b/automerge-kotlin/src/main/kotlin/org/automerge/kotlin/RepoExtensions.kt new file mode 100644 index 0000000..4429ec8 --- /dev/null +++ b/automerge-kotlin/src/main/kotlin/org/automerge/kotlin/RepoExtensions.kt @@ -0,0 +1,15 @@ +package org.automerge.kotlin + +import kotlinx.coroutines.future.await +import org.automerge.repo.AutomergeUrl +import org.automerge.repo.DocHandle +import org.automerge.repo.DocumentId +import org.automerge.repo.Repo + +/** Suspend-friendly [Repo.find] that returns `null` instead of [java.util.Optional.empty]. */ +suspend fun Repo.findDocument(documentId: DocumentId): DocHandle? = + find(documentId).await().orElse(null) + +/** Suspend-friendly [Repo.find] that returns `null` instead of [java.util.Optional.empty]. */ +suspend fun Repo.findDocument(url: AutomergeUrl): DocHandle? = + find(url).await().orElse(null) diff --git a/automerge-kotlin/src/test/kotlin/org/automerge/kotlin/DocHandleExtensionsTest.kt b/automerge-kotlin/src/test/kotlin/org/automerge/kotlin/DocHandleExtensionsTest.kt new file mode 100644 index 0000000..f9dc8aa --- /dev/null +++ b/automerge-kotlin/src/test/kotlin/org/automerge/kotlin/DocHandleExtensionsTest.kt @@ -0,0 +1,174 @@ +package org.automerge.kotlin + +import kotlinx.coroutines.cancel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.automerge.ObjectId +import org.automerge.repo.DocHandle +import org.automerge.repo.Repo +import org.automerge.repo.RepoConfig +import org.automerge.repo.storage.InMemoryStorage +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.util.concurrent.TimeUnit + +class DocHandleExtensionsTest { + + private fun withRepo(block: (Repo, DocHandle) -> Unit) { + val config = RepoConfig.builder().storage(InMemoryStorage()).build() + Repo.load(config).use { repo -> + val handle = repo.create().get(5, TimeUnit.SECONDS) + block(repo, handle) + } + } + + @Test + fun `observe with initialValue returns initial before first change`() { + withRepo { _, handle -> + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + try { + val flow = handle.observe(scope, "default") { doc -> + doc.getString(ObjectId.ROOT, "title") ?: "default" + } + assertEquals("default", flow.value) + } finally { + scope.cancel() + } + } + } + + @Test + fun `observe with initialValue updates on change`() { + withRepo { _, handle -> + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + try { + val flow = handle.observe(scope, null as String?) { doc -> + doc.getString(ObjectId.ROOT, "title") + } + + // Wait for initial async evaluation + Thread.sleep(500) + + // Initially null since no data set yet + assertNull(flow.value) + + // Make a change + handle.withDocument { doc -> + doc.startTransaction().use { tx -> + tx.set(ObjectId.ROOT, "title", "Hello") + tx.commit() + } + null + }.get(5, TimeUnit.SECONDS) + + // Wait for the flow to process the change + Thread.sleep(1000) + + assertEquals("Hello", flow.value) + } finally { + scope.cancel() + } + } + } + + @Test + fun `suspend observe gets accurate initial value`() { + withRepo { _, handle -> + // Set up some data first + handle.withDocument { doc -> + doc.startTransaction().use { tx -> + tx.set(ObjectId.ROOT, "name", "Alice") + tx.commit() + } + null + }.get(5, TimeUnit.SECONDS) + + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + try { + val flow = runBlocking { + handle.observe(scope) { doc -> + doc.getString(ObjectId.ROOT, "name") + } + } + + assertEquals("Alice", flow.value) + } finally { + scope.cancel() + } + } + } + + @Test + fun `observeString convenience`() { + withRepo { _, handle -> + handle.withDocument { doc -> + doc.startTransaction().use { tx -> + tx.set(ObjectId.ROOT, "greeting", "hi") + tx.commit() + } + null + }.get(5, TimeUnit.SECONDS) + + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + try { + val flow = handle.observeString(scope, ObjectId.ROOT, "greeting") + + // Wait for async initial evaluation + Thread.sleep(1000) + assertEquals("hi", flow.value) + } finally { + scope.cancel() + } + } + } + + @Test + fun `observeLong convenience`() { + withRepo { _, handle -> + handle.withDocument { doc -> + doc.startTransaction().use { tx -> + tx.set(ObjectId.ROOT, "count", 42) + tx.commit() + } + null + }.get(5, TimeUnit.SECONDS) + + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + try { + val flow = handle.observeLong(scope, ObjectId.ROOT, "count") + + // Wait for async initial evaluation + Thread.sleep(1000) + assertEquals(42L, flow.value) + } finally { + scope.cancel() + } + } + } + + @Test + fun `withDocAsync bridge`() { + withRepo { _, handle -> + val result = runBlocking { + handle.withDocAsync { doc -> + doc.startTransaction().use { tx -> + tx.set(ObjectId.ROOT, "key", "val") + tx.commit() + } + "done" + } + } + assertEquals("done", result) + + // Verify the change + val value = runBlocking { + handle.withDocAsync { doc -> + doc.getString(ObjectId.ROOT, "key") + } + } + assertEquals("val", value) + } + } +} diff --git a/automerge-kotlin/src/test/kotlin/org/automerge/kotlin/DocHandleFlowTest.kt b/automerge-kotlin/src/test/kotlin/org/automerge/kotlin/DocHandleFlowTest.kt new file mode 100644 index 0000000..34c73af --- /dev/null +++ b/automerge-kotlin/src/test/kotlin/org/automerge/kotlin/DocHandleFlowTest.kt @@ -0,0 +1,82 @@ +package org.automerge.kotlin + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.automerge.ObjectId +import org.automerge.repo.DocHandle +import org.automerge.repo.Repo +import org.automerge.repo.RepoConfig +import org.automerge.repo.storage.InMemoryStorage +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.util.concurrent.TimeUnit + +class DocHandleFlowTest { + + private fun withRepo(block: (Repo, DocHandle) -> Unit) { + val config = RepoConfig.builder().storage(InMemoryStorage()).build() + Repo.load(config).use { repo -> + val handle = repo.create().get(5, TimeUnit.SECONDS) + block(repo, handle) + } + } + + @Test + fun `changeFlow emits event on document change`() { + withRepo { _, handle -> + runBlocking { + val job = launch(Dispatchers.Default) { + val event = handle.changeFlow().first() + assertNotNull(event) + assertNotNull(event.newHeads) + assertTrue(event.newHeads.isNotEmpty()) + } + + // Small delay to ensure the flow collector starts and registers the listener + Thread.sleep(100) + + // Make a change to trigger the flow + handle.withDocument { doc -> + doc.startTransaction().use { tx -> + tx.set(ObjectId.ROOT, "key", "value") + tx.commit() + } + null + }.get(5, TimeUnit.SECONDS) + + job.join() + } + } + } + + @Test + fun `changeFlow emits multiple events`() { + withRepo { _, handle -> + runBlocking { + val job = launch(Dispatchers.Default) { + val events = handle.changeFlow().take(3).toList() + assertEquals(3, events.size) + } + + // Small delay to ensure the flow collector starts + Thread.sleep(100) + + repeat(3) { i -> + handle.withDocument { doc -> + doc.startTransaction().use { tx -> + tx.set(ObjectId.ROOT, "key$i", "value$i") + tx.commit() + } + null + }.get(5, TimeUnit.SECONDS) + } + + job.join() + } + } + } +} diff --git a/automerge-kotlin/src/test/kotlin/org/automerge/kotlin/ReadExtensionsTest.kt b/automerge-kotlin/src/test/kotlin/org/automerge/kotlin/ReadExtensionsTest.kt new file mode 100644 index 0000000..07dfa6d --- /dev/null +++ b/automerge-kotlin/src/test/kotlin/org/automerge/kotlin/ReadExtensionsTest.kt @@ -0,0 +1,162 @@ +package org.automerge.kotlin + +import org.automerge.Document +import org.automerge.ObjectId +import org.automerge.ObjectType +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ReadExtensionsTest { + + @Test + fun `getString returns string value from map`() { + val doc = Document() + doc.startTransaction().use { tx -> + tx.set(ObjectId.ROOT, "title", "Hello World") + tx.commit() + } + assertEquals("Hello World", doc.getString(ObjectId.ROOT, "title")) + } + + @Test + fun `getString returns null for missing key`() { + val doc = Document() + assertNull(doc.getString(ObjectId.ROOT, "missing")) + } + + @Test + fun `getString returns null for wrong type`() { + val doc = Document() + doc.startTransaction().use { tx -> + tx.set(ObjectId.ROOT, "count", 42) + tx.commit() + } + assertNull(doc.getString(ObjectId.ROOT, "count")) + } + + @Test + fun `getLong returns long value from map`() { + val doc = Document() + doc.startTransaction().use { tx -> + tx.set(ObjectId.ROOT, "count", 42) + tx.commit() + } + assertEquals(42L, doc.getLong(ObjectId.ROOT, "count")) + } + + @Test + fun `getLong returns null for missing key`() { + val doc = Document() + assertNull(doc.getLong(ObjectId.ROOT, "missing")) + } + + @Test + fun `getDouble returns double value from map`() { + val doc = Document() + doc.startTransaction().use { tx -> + tx.set(ObjectId.ROOT, "pi", 3.14) + tx.commit() + } + assertEquals(3.14, doc.getDouble(ObjectId.ROOT, "pi")) + } + + @Test + fun `getBoolean returns boolean value from map`() { + val doc = Document() + doc.startTransaction().use { tx -> + tx.set(ObjectId.ROOT, "flag", true) + tx.commit() + } + assertEquals(true, doc.getBoolean(ObjectId.ROOT, "flag")) + } + + @Test + fun `getBytes returns byte array from map`() { + val doc = Document() + val data = byteArrayOf(1, 2, 3, 4) + doc.startTransaction().use { tx -> + tx.set(ObjectId.ROOT, "data", data) + tx.commit() + } + val result = doc.getBytes(ObjectId.ROOT, "data") + assertNotNull(result) + assertArrayEquals(data, result) + } + + @Test + fun `getMapId returns ObjectId for nested map`() { + val doc = Document() + var nestedId: ObjectId? = null + doc.startTransaction().use { tx -> + nestedId = tx.set(ObjectId.ROOT, "nested", ObjectType.MAP) + tx.commit() + } + val result = doc.getMapId(ObjectId.ROOT, "nested") + assertNotNull(result) + assertEquals(nestedId, result) + } + + @Test + fun `getListId returns ObjectId for nested list`() { + val doc = Document() + var listId: ObjectId? = null + doc.startTransaction().use { tx -> + listId = tx.set(ObjectId.ROOT, "items", ObjectType.LIST) + tx.commit() + } + val result = doc.getListId(ObjectId.ROOT, "items") + assertNotNull(result) + assertEquals(listId, result) + } + + @Test + fun `getTextId returns ObjectId for nested text`() { + val doc = Document() + var textId: ObjectId? = null + doc.startTransaction().use { tx -> + textId = tx.set(ObjectId.ROOT, "content", ObjectType.TEXT) + tx.commit() + } + val result = doc.getTextId(ObjectId.ROOT, "content") + assertNotNull(result) + assertEquals(textId, result) + } + + @Test + fun `getString from list by index`() { + val doc = Document() + doc.startTransaction().use { tx -> + val listId = tx.set(ObjectId.ROOT, "items", ObjectType.LIST) + tx.insert(listId, 0, "first") + tx.insert(listId, 1, "second") + tx.commit() + } + val listId = doc.getListId(ObjectId.ROOT, "items")!! + assertEquals("first", doc.getString(listId, 0L)) + assertEquals("second", doc.getString(listId, 1L)) + } + + @Test + fun `getLong from list by index`() { + val doc = Document() + doc.startTransaction().use { tx -> + val listId = tx.set(ObjectId.ROOT, "numbers", ObjectType.LIST) + tx.insert(listId, 0, 10) + tx.insert(listId, 1, 20) + tx.commit() + } + val listId = doc.getListId(ObjectId.ROOT, "numbers")!! + assertEquals(10L, doc.getLong(listId, 0L)) + assertEquals(20L, doc.getLong(listId, 1L)) + } + + @Test + fun `getMapId returns null for non-map value`() { + val doc = Document() + doc.startTransaction().use { tx -> + tx.set(ObjectId.ROOT, "name", "Alice") + tx.commit() + } + assertNull(doc.getMapId(ObjectId.ROOT, "name")) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index afbe97d..399727a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.diffplug.spotless") version "6.18.0" apply false id("org.danilopianini.publish-on-central") version "9.1.7" apply false + id("org.jetbrains.dokka") version "2.0.0" apply false } fun readCargoVersion(): String { diff --git a/gradle.properties b/gradle.properties index 0485603..1fd8902 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,8 @@ # Enable AndroidX for Android modules # This is required for android-test-app which uses AndroidX test dependencies android.useAndroidX=true + +# Opt into Dokka Gradle plugin V2 (V1 is being removed in Dokka 2.1.0). +# Enables the dokka { ... } DSL and the dokkaGenerate aggregate task. +org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled +org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true diff --git a/settings.gradle.kts b/settings.gradle.kts index 5122488..3f43025 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,11 +15,13 @@ pluginManagement { } plugins { id("com.android.library") + kotlin("jvm") version "2.2.0" } } rootProject.name = "automerge-java" include("lib") include("websocket") +include("automerge-kotlin") include("android") include("android-test-app")