tests/testthat/test-marks.R

test_that("am_mark creates marks on text ranges", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Mark "hello" (positions 0-4) as bold
  am_mark(text_obj, 0, 5, "bold", TRUE)

  # Get marks
  marks <- am_marks(text_obj)
  expect_length(marks, 1)
  expect_equal(marks[[1]]$name, "bold")
  expect_equal(marks[[1]]$value, TRUE)
  expect_equal(marks[[1]]$start, 0)
  expect_equal(marks[[1]]$end, 5)
})

test_that("multiple marks can exist on same text", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Add multiple marks
  am_mark(text_obj, 0, 5, "bold", TRUE)
  am_mark(text_obj, 6, 11, "italic", TRUE)
  am_mark(text_obj, 2, 8, "underline", TRUE)

  # Get all marks
  marks <- am_marks(text_obj)
  expect_length(marks, 3)

  # Check mark names
  mark_names <- sapply(marks, function(m) m$name)
  expect_setequal(mark_names, c("bold", "italic", "underline"))
})

test_that("mark expand mode 'none' works correctly", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Mark with expand = "none" (positions 0-4 cover "hello")
  am_mark(text_obj, 0, 5, "bold", TRUE, expand = AM_MARK_EXPAND_NONE)

  # Insert text at start boundary (position 0)
  am_text_splice(text_obj, 0, 0, "X")

  # Mark should not expand to include new text
  marks <- am_marks(text_obj)
  expect_equal(marks[[1]]$start, 1)  # Shifted by 1
  expect_equal(marks[[1]]$end, 6)    # Shifted by 1

  # Insert text at end boundary (position 6)
  am_text_splice(text_obj, 6, 0, "Y")

  # Mark should not include new text at end
  marks <- am_marks(text_obj)
  expect_equal(marks[[1]]$start, 1)
  expect_equal(marks[[1]]$end, 6)  # Does not include "Y"
})

test_that("mark expand mode 'before' can be set", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Mark with expand = "before"
  am_mark(text_obj, 0, 5, "bold", TRUE, expand = AM_MARK_EXPAND_BEFORE)

  marks <- am_marks(text_obj)
  expect_length(marks, 1)
  expect_equal(marks[[1]]$name, "bold")
})

test_that("mark expand mode 'after' can be set", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Mark with expand = "after"
  am_mark(text_obj, 0, 5, "bold", TRUE, expand = AM_MARK_EXPAND_AFTER)

  marks <- am_marks(text_obj)
  expect_length(marks, 1)
  expect_equal(marks[[1]]$name, "bold")
})

test_that("mark expand mode 'both' can be set", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Mark with expand = "both"
  am_mark(text_obj, 0, 5, "bold", TRUE, expand = AM_MARK_EXPAND_BOTH)

  marks <- am_marks(text_obj)
  expect_length(marks, 1)
  expect_equal(marks[[1]]$name, "bold")
})

test_that("mark values support various types", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Boolean value
  am_mark(text_obj, 0, 1, "bool", TRUE)

  # Integer value
  am_mark(text_obj, 1, 2, "int", 42L)

  # Numeric value
  am_mark(text_obj, 2, 3, "num", 3.14)

  # String value
  am_mark(text_obj, 3, 4, "str", "test")

  # Note: NULL values are accepted but don't create visible marks
  # (NULL is used to clear/remove marks in automerge-c)
  am_mark(text_obj, 4, 5, "null", NULL)

  marks <- am_marks(text_obj)
  expect_length(marks, 4)  # NULL mark doesn't appear in results

  # Verify values
  expect_equal(marks[[1]]$value, TRUE)
  expect_equal(marks[[2]]$value, 42L)
  expect_equal(marks[[3]]$value, 3.14)
  expect_equal(marks[[4]]$value, "test")
})

test_that("am_marks_at returns marks at specific position", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Create overlapping marks
  am_mark(text_obj, 0, 5, "bold", TRUE)      # Covers positions 0-4
  am_mark(text_obj, 2, 8, "underline", TRUE) # Covers positions 2-7
  am_mark(text_obj, 6, 11, "italic", TRUE)   # Covers positions 6-10

  # Position 0: only "bold"
  marks_at_0 <- am_marks_at(text_obj, 0)
  expect_length(marks_at_0, 1)
  expect_equal(marks_at_0[[1]]$name, "bold")

  # Position 3: "bold" and "underline"
  marks_at_3 <- am_marks_at(text_obj, 3)
  expect_length(marks_at_3, 2)
  mark_names <- sapply(marks_at_3, function(m) m$name)
  expect_setequal(mark_names, c("bold", "underline"))

  # Position 6: "underline" and "italic"
  marks_at_6 <- am_marks_at(text_obj, 6)
  expect_length(marks_at_6, 2)
  mark_names <- sapply(marks_at_6, function(m) m$name)
  expect_setequal(mark_names, c("underline", "italic"))

  # Position 9: only "italic"
  marks_at_9 <- am_marks_at(text_obj, 9)
  expect_length(marks_at_9, 1)
  expect_equal(marks_at_9[[1]]$name, "italic")

  # Position outside all marks
  marks_at_11 <- am_marks_at(text_obj, 11)
  expect_length(marks_at_11, 0)
})

test_that("marks work with UTF-32 character indexing", {
  doc <- am_create()
  # Text with emoji (single character in UTF-32)
  am_put(doc, AM_ROOT, "text", am_text("Hello😀World"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Mark the emoji (position 5, which is where emoji is)
  am_mark(text_obj, 5, 6, "emoji", TRUE)

  marks <- am_marks(text_obj)
  expect_equal(marks[[1]]$start, 5)
  expect_equal(marks[[1]]$end, 6)
  expect_equal(marks[[1]]$name, "emoji")
})

test_that("mark validation rejects invalid inputs", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Invalid start position
  expect_error(am_mark(text_obj, -1, 3, "test", TRUE),
               "start must be non-negative")
  expect_error(am_mark(text_obj, "a", 3, "test", TRUE),
               "start must be numeric")

  # Invalid end position
  expect_error(am_mark(text_obj, 1, -1, "test", TRUE),
               "end must be non-negative")
  expect_error(am_mark(text_obj, 1, "a", "test", TRUE),
               "end must be numeric")

  # End before or equal to start
  expect_error(am_mark(text_obj, 5, 3, "test", TRUE),
               "end must be greater than start")
  expect_error(am_mark(text_obj, 3, 3, "test", TRUE),
               "end must be greater than start")

  # Invalid name
  expect_error(am_mark(text_obj, 1, 3, c("a", "b"), TRUE),
               "name must be a single character string")

  # Invalid expand mode
  expect_error(am_mark(text_obj, 1, 3, "test", TRUE, expand = "invalid"),
               "Invalid expand value")
  expect_error(am_mark(text_obj, 1, 3, "test", TRUE, expand = 123),
               "expand must be a single character string")
})

test_that("marks with counter and timestamp values", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Counter value
  counter <- structure(5L, class = "am_counter")
  am_mark(text_obj, 0, 2, "counter", counter)

  # Timestamp value
  timestamp <- as.POSIXct("2025-01-01 12:00:00", tz = "UTC")
  am_mark(text_obj, 3, 5, "timestamp", timestamp)

  marks <- am_marks(text_obj)
  expect_length(marks, 2)

  # Verify counter value
  expect_s3_class(marks[[1]]$value, "am_counter")
  expect_equal(as.integer(marks[[1]]$value), 5L)

  # Verify timestamp value
  expect_s3_class(marks[[2]]$value, "POSIXct")
})

test_that("marks return empty list when no marks exist", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  marks <- am_marks(text_obj)
  expect_length(marks, 0)

  marks_at_5 <- am_marks_at(text_obj, 5)
  expect_length(marks_at_5, 0)
})

test_that("marks work across document commits", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Create mark before commit
  am_mark(text_obj, 0, 5, "bold", TRUE)
  am_commit(doc, "Add bold mark")

  # Mark should still exist after commit
  marks <- am_marks(text_obj)
  expect_length(marks, 1)
  expect_equal(marks[[1]]$name, "bold")

  # Add another mark after commit
  am_mark(text_obj, 6, 11, "italic", TRUE)
  am_commit(doc, "Add italic mark")

  # Both marks should exist
  marks <- am_marks(text_obj)
  expect_length(marks, 2)
})

test_that("marks support raw bytes values", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Create mark with raw bytes
  raw_data <- as.raw(c(0x48, 0x65, 0x6c, 0x6c, 0x6f))
  am_mark(text_obj, 0, 5, "data", raw_data)

  # Retrieve and verify
  marks <- am_marks(text_obj)
  expect_length(marks, 1)
  expect_equal(marks[[1]]$name, "data")
  expect_type(marks[[1]]$value, "raw")
  expect_equal(marks[[1]]$value, raw_data)
})

test_that("mark values reject non-scalar POSIXct", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Vector POSIXct should fail
  timestamps <- as.POSIXct(c("2025-01-01 12:00:00", "2025-01-02 12:00:00"), tz = "UTC")
  expect_error(
    am_mark(text_obj, 0, 5, "timestamp", timestamps),
    "Mark value must be scalar"
  )
})

test_that("mark values reject non-scalar counters", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Vector counter should fail
  counters <- structure(c(1L, 2L), class = "am_counter")
  expect_error(
    am_mark(text_obj, 0, 5, "counter", counters),
    "Counter must be a scalar integer"
  )
})

test_that("mark values reject unsupported types", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # List should fail
  expect_error(
    am_mark(text_obj, 0, 5, "test", list(a = 1)),
    "Unsupported mark value type"
  )

  # Function should fail
  expect_error(
    am_mark(text_obj, 0, 5, "test", function() {}),
    "Unsupported mark value type"
  )
})

test_that("mark expand mode 'after' expands correctly", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Mark "hello" with expand = "after"
  am_mark(text_obj, 0, 5, "bold", TRUE, expand = AM_MARK_EXPAND_AFTER)

  # Insert text at end boundary (position 5, after "hello")
  am_text_splice(text_obj, 5, 0, "X")

  # Mark should expand to include "X"
  marks <- am_marks(text_obj)
  expect_equal(marks[[1]]$end, 6)
})

test_that("mark expand mode 'before' expands correctly", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Mark "hello" with expand = "before"
  am_mark(text_obj, 0, 5, "bold", TRUE, expand = AM_MARK_EXPAND_BEFORE)

  # Insert text at start boundary (position 0, before "hello")
  am_text_splice(text_obj, 0, 0, "X")

  # Mark should expand to include "X"
  marks <- am_marks(text_obj)
  expect_equal(marks[[1]]$start, 0)
  expect_equal(marks[[1]]$end, 6)
})

test_that("mark expand mode 'both' expands in both directions", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Mark "hello" with expand = "both"
  am_mark(text_obj, 0, 5, "bold", TRUE, expand = AM_MARK_EXPAND_BOTH)

  # Insert text at start
  am_text_splice(text_obj, 0, 0, "X")
  marks <- am_marks(text_obj)
  expect_equal(marks[[1]]$start, 0)
  expect_equal(marks[[1]]$end, 6)

  # Insert text at end
  am_text_splice(text_obj, 6, 0, "Y")
  marks <- am_marks(text_obj)
  expect_equal(marks[[1]]$start, 0)
  expect_equal(marks[[1]]$end, 7)
})

test_that("am_uint64 mark values round-trip correctly", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("Hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  am_mark(text_obj, 0, 5, "revision", am_uint64(12345))

  marks <- am_marks(text_obj)
  expect_length(marks, 1)
  expect_s3_class(marks[[1]]$value, "am_uint64")
  expect_equal(as.numeric(marks[[1]]$value), 12345)
})

# am_uint64 Snapshot Tests -----------------------------------------------------

test_that("am_mark with invalid am_uint64 errors", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("Hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  # Non-scalar am_uint64
  bad_uint <- structure(c(1, 2), class = "am_uint64")
  expect_snapshot(error = TRUE, {
    am_mark(text_obj, 0, 5, "bad", bad_uint)
  })

  # Non-numeric am_uint64
  bad_uint2 <- structure(1L, class = "am_uint64")
  expect_snapshot(error = TRUE, {
    am_mark(text_obj, 0, 5, "bad", bad_uint2)
  })
})

test_that("am_marks warns for uint64 exceeding 2^53", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("Hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  suppressWarnings(am_mark(text_obj, 0, 5, "big", am_uint64(2^54)))

  expect_snapshot({
    am_marks(text_obj)
  })
})

test_that("am_marks_at warns for uint64 exceeding 2^53", {
  doc <- am_create()
  am_put(doc, AM_ROOT, "text", am_text("Hello world"))
  text_obj <- am_get(doc, AM_ROOT, "text")

  suppressWarnings(am_mark(text_obj, 0, 5, "big", am_uint64(2^54)))

  expect_snapshot({
    am_marks_at(text_obj, 2)
  })
})

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.