tests/testthat/test-sas-missing.R

# Special-missing (.A-.Z, ._) carriage through every codec.
#
# The sas_missing column attribute is the in-session canonical form (what the
# xpt layer produces and consumes); on disk the tags ride the namespaced
# `_artoo.specialMissings` block so json/parquet round-trip them while the
# data values stay plain nulls (foreign readers degrade gracefully).

# A small AE-shaped frame with tagged numeric missings: ".A" (not done),
# ".Z" (not applicable), "._" (underscore missing). Plain NA stays untagged.
.tagged_frame <- function() {
  df <- data.frame(
    USUBJID = c("01-001", "01-002", "01-003", "01-004", "01-005"),
    AESEQ = c(1L, 2L, 3L, 4L, 5L),
    AENDY = c(10, NA, NA, 40, NA),
    stringsAsFactors = FALSE
  )
  attr(df$AENDY, "sas_missing") <- c(NA, ".A", "._", NA, ".Z")
  df
}

# ---- helper unit behaviour --------------------------------------------------

test_that(".collect_special_missings gathers only non-'.' tags at NA cells", {
  df <- .tagged_frame()
  sm <- artoo:::.collect_special_missings(df)
  expect_named(sm, "AENDY")
  expect_identical(sm$AENDY$rows, c(2L, 3L, 5L))
  expect_identical(sm$AENDY$tags, c(".A", "._", ".Z"))

  # "." is the default meaning of an on-disk null: never collected.
  plain <- data.frame(AVAL = c(1, NA))
  attr(plain$AVAL, "sas_missing") <- c(NA, ".")
  expect_null(artoo:::.collect_special_missings(plain))

  # A tag on a non-NA cell is stale; it is ignored, not carried.
  stale <- data.frame(AVAL = c(1, 2))
  attr(stale$AVAL, "sas_missing") <- c(".A", NA)
  expect_null(artoo:::.collect_special_missings(stale))

  # No tags anywhere -> NULL, so codecs can test emptiness cheaply.
  expect_null(artoo:::.collect_special_missings(data.frame(AVAL = c(1, NA))))
})

test_that(".apply_special_missings reattaches tags and skips unknown columns", {
  df <- data.frame(AENDY = c(10, NA, NA, 40, NA))
  sm <- list(
    AENDY = list(rows = c(2L, 5L), tags = c(".A", ".Z")),
    GHOST = list(rows = 1L, tags = ".B")
  )
  out <- artoo:::.apply_special_missings(df, sm)
  expect_identical(
    attr(out$AENDY, "sas_missing"),
    c(NA, ".A", NA, NA, ".Z")
  )
  expect_identical(names(out), "AENDY")
})

test_that(".subset_special_missings aligns tags to a row subset", {
  tags <- c(NA, ".A", "._", NA, ".Z")
  expect_identical(
    artoo:::.subset_special_missings(tags, 1:3),
    c(NA, ".A", "._")
  )
  expect_identical(artoo:::.subset_special_missings(NULL, 1:3), NULL)
})

# ---- the losslessness legs --------------------------------------------------

test_that("special missings survive xpt -> json -> xpt", {
  df <- .tagged_frame()
  xpt1 <- withr::local_tempfile(fileext = ".xpt")
  jsn <- withr::local_tempfile(fileext = ".json")
  xpt2 <- withr::local_tempfile(fileext = ".xpt")

  write_xpt(df, xpt1)
  a <- read_xpt(xpt1)
  write_json(a, jsn)
  b <- read_json(jsn)
  expect_identical(
    attr(b$AENDY, "sas_missing")[c(2, 3, 5)],
    c(".A", "._", ".Z")
  )
  write_xpt(b, xpt2)
  c2 <- read_xpt(xpt2)
  # Byte-level proof: the second xpt carries the same indicator bytes.
  expect_identical(
    attr(c2$AENDY, "sas_missing"),
    attr(a$AENDY, "sas_missing")
  )
  expect_identical(c2$AENDY, a$AENDY)
})

test_that("special missings survive xpt -> parquet -> xpt", {
  skip_if_not_installed("nanoparquet")
  df <- .tagged_frame()
  xpt1 <- withr::local_tempfile(fileext = ".xpt")
  pq <- withr::local_tempfile(fileext = ".parquet")
  xpt2 <- withr::local_tempfile(fileext = ".xpt")

  write_xpt(df, xpt1)
  a <- read_xpt(xpt1)
  write_parquet(a, pq)
  b <- read_parquet(pq)
  expect_identical(
    attr(b$AENDY, "sas_missing")[c(2, 3, 5)],
    c(".A", "._", ".Z")
  )
  write_xpt(b, xpt2)
  c2 <- read_xpt(xpt2)
  expect_identical(
    attr(c2$AENDY, "sas_missing"),
    attr(a$AENDY, "sas_missing")
  )
})

test_that("special missings survive the full chain json -> parquet -> rds", {
  skip_if_not_installed("nanoparquet")
  df <- .tagged_frame()
  jsn <- withr::local_tempfile(fileext = ".json")
  pq <- withr::local_tempfile(fileext = ".parquet")
  rds <- withr::local_tempfile(fileext = ".rds")

  write_json(df, jsn)
  a <- read_json(jsn)
  write_parquet(a, pq)
  b <- read_parquet(pq)
  write_rds(b, rds)
  c2 <- read_rds(rds)
  expect_identical(
    attr(c2$AENDY, "sas_missing")[c(2, 3, 5)],
    c(".A", "._", ".Z")
  )
  expect_identical(c2$AENDY, df$AENDY, ignore_attr = FALSE)
})

test_that("n_max keeps and aligns tags on json and parquet reads", {
  df <- .tagged_frame()
  jsn <- withr::local_tempfile(fileext = ".json")
  write_json(df, jsn)
  part <- read_json(jsn, n_max = 3)
  expect_identical(attr(part$AENDY, "sas_missing"), c(NA, ".A", "._"))

  skip_if_not_installed("nanoparquet")
  pq <- withr::local_tempfile(fileext = ".parquet")
  write_parquet(df, pq)
  partp <- read_parquet(pq, n_max = 3)
  expect_identical(attr(partp$AENDY, "sas_missing"), c(NA, ".A", "._"))
})

test_that("col_select keeps tags on the kept columns", {
  df <- .tagged_frame()
  jsn <- withr::local_tempfile(fileext = ".json")
  write_json(df, jsn)
  part <- read_json(jsn, col_select = c("USUBJID", "AENDY"))
  expect_identical(
    attr(part$AENDY, "sas_missing"),
    c(NA, ".A", "._", NA, ".Z")
  )
})

test_that("tagged temporal columns carry their tags through json", {
  df <- data.frame(
    USUBJID = c("01-001", "01-002", "01-003"),
    ADT = as.Date(c("2024-03-01", NA, "2024-03-15")),
    stringsAsFactors = FALSE
  )
  attr(df$ADT, "sas_missing") <- c(NA, ".A", NA)
  jsn <- withr::local_tempfile(fileext = ".json")
  write_json(df, jsn)
  back <- read_json(jsn)
  expect_s3_class(back$ADT, "Date")
  expect_identical(attr(back$ADT, "sas_missing"), c(NA, ".A", NA))
})

test_that("tags survive apply_spec conformance", {
  spec <- artoo_spec(
    data.frame(dataset = "AE", label = "Adverse Events"),
    data.frame(
      dataset = c("AE", "AE", "AE"),
      variable = c("USUBJID", "AESEQ", "AENDY"),
      label = c("Subject", "Sequence", "End Day"),
      data_type = c("string", "integer", "float"),
      stringsAsFactors = FALSE
    )
  )
  df <- .tagged_frame()
  out <- apply_spec(df, spec, "AE", conformance = "off")
  expect_identical(
    attr(out$AENDY, "sas_missing"),
    c(NA, ".A", "._", NA, ".Z")
  )
})

# ---- on-disk shape: graceful degradation ------------------------------------

test_that("the json file stays valid Dataset-JSON with plain nulls", {
  df <- .tagged_frame()
  jsn <- withr::local_tempfile(fileext = ".json")
  write_json(df, jsn)
  p <- jsonlite::fromJSON(jsn, simplifyVector = FALSE)
  # Data values are ordinary nulls — a foreign reader sees plain missings.
  expect_null(p$rows[[2]][[3]])
  expect_null(p$rows[[5]][[3]])
  # The tags ride the namespaced extension, version-stamped.
  expect_identical(p[["_artoo"]]$artooMetaVersion, "1.0")
  expect_identical(
    unlist(p[["_artoo"]]$specialMissings$AENDY$tags),
    c(".A", "._", ".Z")
  )
})

test_that("a frame with no extension content writes strict-CDISC json", {
  df <- data.frame(USUBJID = c("01-001", "01-002"), AVAL = c(1, NA))
  jsn <- withr::local_tempfile(fileext = ".json")
  write_json(df, jsn)
  p <- jsonlite::fromJSON(jsn, simplifyVector = FALSE)
  expect_false("_artoo" %in% names(p))
})

test_that("write_json(strict = TRUE) suppresses _artoo with a loss warning", {
  df <- .tagged_frame()
  jsn <- withr::local_tempfile(fileext = ".json")
  expect_warning(
    write_json(df, jsn, strict = TRUE),
    class = "artoo_warning_codec"
  )
  p <- jsonlite::fromJSON(jsn, simplifyVector = FALSE)
  expect_false("_artoo" %in% names(p))
  back <- suppressWarnings({
    write_json(df, jsn, strict = TRUE)
    read_json(jsn)
  })
  expect_null(attr(back$AENDY, "sas_missing"))
})

test_that("pyreadstat reads tagged cells as plain missing", {
  skip_on_cran()
  py <- py_with_pyreadstat()
  skip_if(py == "", "python3 + pyreadstat not available")
  df <- .tagged_frame()
  xpt <- withr::local_tempfile(fileext = ".xpt")
  write_xpt(df, xpt)
  script <- withr::local_tempfile(fileext = ".py")
  writeLines(
    c(
      "import sys, pyreadstat",
      sprintf("df, meta = pyreadstat.read_xport(r'%s')", xpt),
      "print(int(df['AENDY'].isna().sum()))"
    ),
    script
  )
  out <- system2(py, script, stdout = TRUE)
  expect_identical(tail(out, 1L), "3")
})

Try the artoo package in your browser

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

artoo documentation built on July 23, 2026, 1:08 a.m.