tests/testthat/test-sync.R

test_that("am_sync_state_new creates a valid sync state", {
  sync_state <- am_sync_state_new()
  expect_s3_class(sync_state, "am_syncstate")
  expect_type(sync_state, "externalptr")
})

test_that("am_sync_encode/decode work with empty documents", {
  doc1 <- am_create()
  doc2 <- am_create()

  sync1 <- am_sync_state_new()
  sync2 <- am_sync_state_new()

  # First message from doc1
  msg1 <- am_sync_encode(doc1, sync1)
  expect_type(msg1, "raw")
  expect_gt(length(msg1), 0)

  # Receive in doc2
  am_sync_decode(doc2, sync2, msg1)

  # doc2 responds
  msg2 <- am_sync_encode(doc2, sync2)
  if (!is.null(msg2)) {
    am_sync_decode(doc1, sync1, msg2)
  }

  # Eventually both should return NULL (no more messages)
  for (i in 1:10) {
    msg1 <- am_sync_encode(doc1, sync1)
    msg2 <- am_sync_encode(doc2, sync2)
    if (is.null(msg1) && is.null(msg2)) {
      break
    }
    if (!is.null(msg1)) {
      am_sync_decode(doc2, sync2, msg1)
    }
    if (!is.null(msg2)) am_sync_decode(doc1, sync1, msg2)
  }

  expect_null(am_sync_encode(doc1, sync1))
  expect_null(am_sync_encode(doc2, sync2))
})

test_that("am_sync_encode/decode synchronize simple changes", {
  # Create two documents with different changes
  doc1 <- am_create()
  doc2 <- am_create()

  # Make changes in doc1
  am_put(doc1, AM_ROOT, "x", 1)
  am_commit(doc1, "Add x")

  # Make changes in doc2
  am_put(doc2, AM_ROOT, "y", 2)
  am_commit(doc2, "Add y")

  # Verify they're different before sync
  expect_null(am_get(doc1, AM_ROOT, "y"))
  expect_null(am_get(doc2, AM_ROOT, "x"))

  # Create sync states
  sync1 <- am_sync_state_new()
  sync2 <- am_sync_state_new()

  # Exchange messages until convergence
  for (round in 1:20) {
    msg1 <- am_sync_encode(doc1, sync1)
    msg2 <- am_sync_encode(doc2, sync2)

    if (is.null(msg1) && is.null(msg2)) {
      break
    }

    if (!is.null(msg1)) {
      am_sync_decode(doc2, sync2, msg1)
    }
    if (!is.null(msg2)) am_sync_decode(doc1, sync1, msg2)
  }

  # Both documents should now have both x and y
  expect_equal(am_get(doc1, AM_ROOT, "x"), 1)
  expect_equal(am_get(doc1, AM_ROOT, "y"), 2)
  expect_equal(am_get(doc2, AM_ROOT, "x"), 1)
  expect_equal(am_get(doc2, AM_ROOT, "y"), 2)
})

test_that("am_sync synchronizes two documents", {
  doc1 <- am_create()
  doc2 <- am_create()

  # Make different changes in each document
  am_put(doc1, AM_ROOT, "a", 1)
  am_put(doc1, AM_ROOT, "b", 2)
  am_commit(doc1)

  am_put(doc2, AM_ROOT, "c", 3)
  am_put(doc2, AM_ROOT, "d", 4)
  am_commit(doc2)

  # Sync using high-level helper
  rounds <- am_sync(doc1, doc2)

  expect_type(rounds, "double")
  expect_gt(rounds, 0)
  expect_lte(rounds, 100)

  # Both documents should have all four keys
  expect_equal(am_get(doc1, AM_ROOT, "a"), 1)
  expect_equal(am_get(doc1, AM_ROOT, "b"), 2)
  expect_equal(am_get(doc1, AM_ROOT, "c"), 3)
  expect_equal(am_get(doc1, AM_ROOT, "d"), 4)

  expect_equal(am_get(doc2, AM_ROOT, "a"), 1)
  expect_equal(am_get(doc2, AM_ROOT, "b"), 2)
  expect_equal(am_get(doc2, AM_ROOT, "c"), 3)
  expect_equal(am_get(doc2, AM_ROOT, "d"), 4)
})

test_that("am_sync handles concurrent edits", {
  # Start with synchronized documents
  doc1 <- am_create()
  am_put(doc1, AM_ROOT, "counter", 0)
  am_commit(doc1)

  # Fork to create doc2
  doc2 <- am_fork(doc1)

  # Make concurrent changes
  am_put(doc1, AM_ROOT, "counter", 1)
  am_put(doc1, AM_ROOT, "x", "from_doc1")
  am_commit(doc1, "Doc1 update")

  am_put(doc2, AM_ROOT, "counter", 10)
  am_put(doc2, AM_ROOT, "y", "from_doc2")
  am_commit(doc2, "Doc2 update")

  # Sync them
  rounds <- am_sync(doc1, doc2)
  expect_gt(rounds, 0)


  # Both should have both x and y
  expect_equal(am_get(doc1, AM_ROOT, "x"), "from_doc1")
  expect_equal(am_get(doc1, AM_ROOT, "y"), "from_doc2")
  expect_equal(am_get(doc2, AM_ROOT, "x"), "from_doc1")
  expect_equal(am_get(doc2, AM_ROOT, "y"), "from_doc2")

  # Counter should have a conflict resolved by Automerge CRDT semantics
  # (both values exist, but one is selected as the "winner")
  counter1 <- am_get(doc1, AM_ROOT, "counter")
  counter2 <- am_get(doc2, AM_ROOT, "counter")
  expect_equal(counter1, counter2) # Should be the same in both docs
})

test_that("am_get_heads returns current document heads", {
  doc <- am_create()

  # New document should have empty heads
  heads <- am_get_heads(doc)
  expect_type(heads, "list")
  expect_equal(length(heads), 0)

  # Make a change and commit
  am_put(doc, AM_ROOT, "x", 1)
  am_commit(doc)

  heads <- am_get_heads(doc)
  expect_equal(length(heads), 1)
  expect_type(heads[[1]], "raw")
  expect_gt(length(heads[[1]]), 0)

  # Make another change
  am_put(doc, AM_ROOT, "y", 2)
  am_commit(doc)

  heads2 <- am_get_heads(doc)
  expect_equal(length(heads2), 1)
  # Heads should have changed
  expect_false(identical(heads[[1]], heads2[[1]]))
})

test_that("am_get_changes returns document history", {
  doc <- am_create()

  # No changes initially
  changes <- am_get_changes(doc, NULL)
  expect_type(changes, "list")
  expect_equal(length(changes), 0)

  # Make some changes
  am_put(doc, AM_ROOT, "x", 1)
  am_commit(doc, "First change")

  am_put(doc, AM_ROOT, "y", 2)
  am_commit(doc, "Second change")

  am_put(doc, AM_ROOT, "z", 3)
  am_commit(doc, "Third change")

  # Get all changes
  changes <- am_get_changes(doc, NULL)
  expect_equal(length(changes), 3)

  # Each change should be a raw vector
  for (change in changes) {
    expect_type(change, "raw")
    expect_gt(length(change), 0)
  }
})

test_that("am_apply_changes applies changes to a document", {
  # Create a document with changes
  doc1 <- am_create()
  am_put(doc1, AM_ROOT, "a", 1)
  am_put(doc1, AM_ROOT, "b", 2)
  am_commit(doc1)

  am_put(doc1, AM_ROOT, "c", 3)
  am_commit(doc1)

  # Get all changes
  changes <- am_get_changes(doc1, NULL)
  expect_gt(length(changes), 0)

  # Create a new document and apply changes
  doc2 <- am_create()
  am_apply_changes(doc2, changes)

  # doc2 should now have the same data as doc1
  expect_equal(am_get(doc2, AM_ROOT, "a"), 1)
  expect_equal(am_get(doc2, AM_ROOT, "b"), 2)
  expect_equal(am_get(doc2, AM_ROOT, "c"), 3)
})

test_that("am_get_history returns full change history", {
  doc <- am_create()

  am_put(doc, AM_ROOT, "v1", "first")
  am_commit(doc, "Version 1")

  am_put(doc, AM_ROOT, "v2", "second")
  am_commit(doc, "Version 2")

  am_put(doc, AM_ROOT, "v3", "third")
  am_commit(doc, "Version 3")

  history <- am_get_history(doc)
  expect_type(history, "list")
  expect_equal(length(history), 3)

  # Each history entry should be a serialized change
  for (entry in history) {
    expect_type(entry, "raw")
  }
})

test_that("sync works with nested objects", {
  doc1 <- am_create()
  doc2 <- am_create()

  # Create nested structure in doc1
  am_put(doc1, AM_ROOT, "config", AM_OBJ_TYPE_MAP)
  map <- am_get(doc1, AM_ROOT, "config")
  am_put(doc1, map, "host", "localhost")
  am_put(doc1, map, "port", 8080)
  am_commit(doc1, "Add config")

  # Create different structure in doc2
  am_put(doc2, AM_ROOT, "items", AM_OBJ_TYPE_LIST)
  list <- am_get(doc2, AM_ROOT, "items")
  am_insert(doc2, list, 1, "first")
  am_insert(doc2, list, 2, "second")
  am_commit(doc2, "Add items")

  # Sync
  rounds <- am_sync(doc1, doc2)
  expect_gt(rounds, 0)

  # Both should have both structures
  config1 <- am_get(doc1, AM_ROOT, "config")
  expect_s3_class(config1, "am_object")
  expect_equal(am_get(doc1, config1, "host"), "localhost")

  items1 <- am_get(doc1, AM_ROOT, "items")
  expect_s3_class(items1, "am_object")
  expect_equal(am_length(doc1, items1), 2)

  config2 <- am_get(doc2, AM_ROOT, "config")
  expect_s3_class(config2, "am_object")

  items2 <- am_get(doc2, AM_ROOT, "items")
  expect_s3_class(items2, "am_object")
})

test_that("sync protocol errors are handled gracefully", {
  doc <- am_create()
  sync_state <- am_sync_state_new()

  # Try to decode invalid sync message
  invalid_msg <- raw(10) # Random bytes
  expect_error(
    am_sync_decode(doc, sync_state, invalid_msg),
    "Automerge error|expected|found"
  )

  # Verify document and sync state are still valid after error
  msg <- am_sync_encode(doc, sync_state)
  expect_true(is.raw(msg) || is.null(msg))
})

test_that("sync state is document-independent", {
  # Create multiple documents
  doc1 <- am_create()
  doc2 <- am_create()
  doc3 <- am_create()

  # Single sync state can be used with different documents
  sync_state <- am_sync_state_new()

  # Use it with doc1
  msg1 <- am_sync_encode(doc1, sync_state)
  expect_true(is.raw(msg1) || is.null(msg1))

  # Use same sync state with doc2 (though this is unusual)
  msg2 <- am_sync_encode(doc2, sync_state)
  expect_true(is.raw(msg2) || is.null(msg2))
})

test_that("am_apply_changes handles empty change list", {
  doc <- am_create()

  # Apply empty changes list
  am_apply_changes(doc, list())

  # Document should still be valid
  am_put(doc, AM_ROOT, "x", 1)
  expect_equal(am_get(doc, AM_ROOT, "x"), 1)
})

test_that("sync works with text objects", {
  doc1 <- am_create()
  doc2 <- am_create()

  # Create text in doc1
  am_put(doc1, AM_ROOT, "notes", AM_OBJ_TYPE_TEXT)
  text1 <- am_get(doc1, AM_ROOT, "notes")
  am_text_splice(text1, 0, 0, "Hello from doc1")
  am_commit(doc1)

  # Create text in doc2
  am_put(doc2, AM_ROOT, "greet", AM_OBJ_TYPE_TEXT)
  text2 <- am_get(doc2, AM_ROOT, "greet")
  am_text_splice(text2, 0, 0, "Hi from doc2")
  am_commit(doc2)

  # Sync
  rounds <- am_sync(doc1, doc2)
  expect_gt(rounds, 0)

  # Both should have both text objects
  notes1 <- am_get(doc1, AM_ROOT, "notes")
  greet1 <- am_get(doc1, AM_ROOT, "greet")
  expect_equal(am_text_content(notes1), "Hello from doc1")
  expect_equal(am_text_content(greet1), "Hi from doc2")

  notes2 <- am_get(doc2, AM_ROOT, "notes")
  greet2 <- am_get(doc2, AM_ROOT, "greet")
  expect_equal(am_text_content(notes2), "Hello from doc1")
  expect_equal(am_text_content(greet2), "Hi from doc2")
})

test_that("am_get_changes with specific heads", {
  doc <- am_create()

  # Make first change
  am_put(doc, AM_ROOT, "x", 1)
  am_commit(doc, "First")
  heads1 <- am_get_heads(doc)

  # Make second change
  am_put(doc, AM_ROOT, "y", 2)
  am_commit(doc, "Second")

  # Make third change
  am_put(doc, AM_ROOT, "z", 3)
  am_commit(doc, "Third")

  # Get changes since heads1 (should get changes 2 and 3)
  changes_since <- am_get_changes(doc, heads1)
  expect_type(changes_since, "list")
  expect_equal(length(changes_since), 2)
})

test_that("am_get_changes_added returns added changes", {
  doc1 <- am_create()
  am_put(doc1, AM_ROOT, "x", 1)
  am_commit(doc1)

  # Fork and make independent changes
  doc2 <- am_fork(doc1)

  am_put(doc2, AM_ROOT, "y", 2)
  am_commit(doc2)

  am_put(doc2, AM_ROOT, "z", 3)
  am_commit(doc2)

  # Get what was added to doc2 since the fork
  # am_get_changes_added(doc1, doc2) returns changes in doc2 not in doc1
  added <- am_get_changes_added(doc1, doc2)
  expect_type(added, "list")
  expect_equal(length(added), 2) # Two commits in doc2
})

Try the automerge package in your browser

Any scripts or data that you put into this service are public.

automerge documentation built on Feb. 5, 2026, 5:08 p.m.