tests/testthat/test-data-read.R

box::use(
  testthat[
    expect_equal,
    expect_error,
    expect_false,
    expect_message,
    expect_true,
    skip_if_not_installed,
    test_that
  ]
)

box::use(
  artma / data / read[read_by_type, read_file, read_data],
  artma / data / normalize[normalize_read_df]
)

# A canonical all-text data frame standing in for the raw output of any reader.
# Every input format should normalize to the same typed result:
#   - "N/A", "null", "NA" become NA (CONST$DATA$NA_STRINGS)
#   - whitespace-only becomes NA
#   - numeric text is coerced to numeric, identifier text stays character
raw_text_df <- function() {
  data.frame(
    study_id = c("S1", "S2", "S3"),
    effect = c("1.5", "N/A", "2.5"),
    se = c("0.1", "0.2", "null"),
    note = c("ok", "NA", "  "),
    stringsAsFactors = FALSE
  )
}

expect_canonical <- function(df) {
  expect_equal(df$study_id, c("S1", "S2", "S3"))
  expect_equal(df$effect, c(1.5, NA, 2.5))
  expect_equal(df$se, c(0.1, 0.2, NA))
  expect_equal(df$note, c("ok", NA, NA))
}


test_that("normalize_read_df replaces NA-strings, whitespace, and coerces types", {
  out <- normalize_read_df(raw_text_df())
  expect_canonical(out)
})


# read_file must normalize every supported input format to the same canonical
# typed result. One registered test per format, each carrying its own optional
# package skip so a missing reader skips only its own case.
read_file_formats <- list(
  list(name = "CSV", ext = ".csv", skip = character(0), write = function(df, path) utils::write.csv(df, path, row.names = FALSE)),
  list(name = "RDS", ext = ".rds", skip = character(0), write = function(df, path) saveRDS(df, path)),
  list(name = "Excel", ext = ".xlsx", skip = c("writexl", "readxl"), write = function(df, path) writexl::write_xlsx(df, path)),
  list(name = "JSON", ext = ".json", skip = "jsonlite", write = function(df, path) jsonlite::write_json(df, path)),
  list(name = "Stata", ext = ".dta", skip = "haven", write = function(df, path) haven::write_dta(df, path))
)

for (case in read_file_formats) {
  test_that(sprintf("read_file normalizes a %s file identically to the canonical result", case$name), {
    for (pkg in case$skip) skip_if_not_installed(pkg)
    withr::local_options(list(artma.verbose = 0))
    tmp <- withr::local_tempfile(fileext = case$ext)
    case$write(raw_text_df(), tmp)

    expect_canonical(read_file(tmp))
  })
}


test_that("read_by_type errors clearly when JSON does not flatten to a data frame", {
  skip_if_not_installed("jsonlite")
  tmp <- withr::local_tempfile(fileext = ".json")
  jsonlite::write_json(list(a = list(1, 2), b = "x"), tmp, auto_unbox = TRUE)

  expect_error(read_by_type(tmp, "json"), "did not flatten to a data frame")
})


test_that("read_data and the shared read_file read a file identically", {
  # Guards against the two call sites (read_data and the options-file column
  # preprocessing, which both go through read_file) diverging again.
  withr::local_options(list(artma.verbose = 0))
  tmp <- withr::local_tempfile(fileext = ".csv")
  utils::write.csv(raw_text_df(), tmp, row.names = FALSE)

  expect_equal(read_data(tmp), read_file(tmp))
})


# -- Comma decimals (issue #554) -----------------------------------------------
# The CSV reader picks `dec` from the file; every other format reaches
# coerce_df_columns with "0,817" still as text, and type.convert leaves it as
# character. The coercer re-parses a column whose values are all comma-decimal
# numbers (plain integers allowed) and reports which columns it touched.

test_that("normalize_read_df re-parses a column of comma-decimal text as numeric", {
  withr::local_options(list(artma.verbose = 0))
  df <- data.frame(
    study_id = c("S1", "S2", "S3", "S4"),
    effect = c("0,817", "-1,25", NA, "2"),
    se = c("0,083", "0,057", "0,4", "0,1"),
    stringsAsFactors = FALSE
  )

  out <- normalize_read_df(df)

  expect_true(is.numeric(out$effect))
  expect_equal(out$effect, c(0.817, -1.25, NA, 2))
  expect_equal(out$se, c(0.083, 0.057, 0.4, 0.1))
  expect_equal(out$study_id, c("S1", "S2", "S3", "S4"))
})

test_that("normalize_read_df leaves mixed or list-like comma text as character", {
  withr::local_options(list(artma.verbose = 0))
  df <- data.frame(
    mixed = c("0,5", "high", "1"),
    codes = c("1,2,3", "4,5", "6,7,8"),
    ints = c("1", "2", "3"),
    stringsAsFactors = FALSE
  )

  out <- normalize_read_df(df)

  expect_true(is.character(out$mixed))
  expect_true(is.character(out$codes))
  expect_equal(out$ints, c(1L, 2L, 3L))
})

test_that("normalize_read_df reports re-parsed comma-decimal columns at info level", {
  withr::local_options(list(artma.verbose = 3))
  df <- data.frame(effect = c("0,1", "0,2"), se = c("0,3", "0,4"), stringsAsFactors = FALSE)

  expect_message(normalize_read_df(df), "comma decimals")
})

test_that("read_file reads a semicolon CSV with comma decimals into numeric effect and se", {
  withr::local_options(list(artma.verbose = 0))
  tmp <- withr::local_tempfile(fileext = ".csv")
  writeLines(c(
    "study_name;sample_size;beta_estimate;beta_se",
    "Abebe et al. (2021);1557;0,817;0,083",
    "Abebe et al. (2021);1559;0,794;0,057"
  ), tmp)

  out <- read_file(tmp)

  expect_true(is.numeric(out$beta_estimate))
  expect_true(is.numeric(out$beta_se))
  expect_equal(out$beta_estimate / out$beta_se, c(0.817 / 0.083, 0.794 / 0.057))
  expect_equal(out$sample_size, c(1557L, 1559L))
})


# -- "na" as a real category value (issue #402) --------------------------------
# Lowercase "na" is a legitimate category value in some real datasets (e.g. "no
# functional form assumed"). Only the exact-case conventional spellings ("NA",
# "N/A", "NULL", "null") are treated as missing-value sentinels; "na" and
# "n/a" must survive untouched.

test_that("normalize_read_df keeps lowercase 'na' as a literal category value", {
  df <- data.frame(
    study_id = c("S1", "S2", "S3"),
    discounting = c("na", "exponential", "na"),
    stringsAsFactors = FALSE
  )

  out <- normalize_read_df(df)

  expect_equal(out$discounting, c("na", "exponential", "na"))
  expect_false(anyNA(out$discounting))
})

test_that("normalize_read_df still treats exact-case 'NA' as missing", {
  df <- data.frame(x = c("NA", "value"), stringsAsFactors = FALSE)

  out <- normalize_read_df(df)

  expect_equal(out$x, c(NA_character_, "value"))
})

test_that("read_file keeps lowercase 'na' as a category value read from a CSV", {
  withr::local_options(list(artma.verbose = 0))
  tmp <- withr::local_tempfile(fileext = ".csv")
  utils::write.csv(
    data.frame(
      study_id = c("S1", "S2", "S3"),
      effect = c(0.1, 0.2, 0.3),
      se = c(0.01, 0.02, 0.03),
      n_obs = c(10L, 20L, 30L),
      discounting = c("na", "exponential", "na"),
      stringsAsFactors = FALSE
    ),
    tmp,
    row.names = FALSE
  )

  out <- read_file(tmp)

  expect_equal(out$discounting, c("na", "exponential", "na"))
  expect_false(anyNA(out$discounting))
})


# -- UTF-8 BOM (issue #556) ----------------------------------------------------
# Excel's "CSV UTF-8" export prefixes the file with EF BB BF. Read naively, the
# bytes become part of the first header name and make.names() renders it as
# "X...obs_id", so the first column loses its identity.

test_that("read_file reads a BOM-prefixed CSV's first column under its real name", {
  withr::local_options(list(artma.verbose = 0))
  plain <- withr::local_tempfile(fileext = ".csv")
  with_bom <- withr::local_tempfile(fileext = ".csv")
  lines <- c(
    "obs_id;study_name;beta_estimate;beta_se",
    "1;Smith (2020);0.5;0.1",
    "2;Jones (2021);0.7;0.2"
  )
  writeLines(lines, plain)
  con <- file(with_bom, open = "wb")
  writeBin(as.raw(c(0xEF, 0xBB, 0xBF)), con)
  writeBin(charToRaw(paste0(paste(lines, collapse = "\n"), "\n")), con)
  close(con)

  df <- read_file(with_bom)

  expect_equal(names(df)[[1]], "obs_id")
  expect_equal(df, read_file(plain))
})

Try the artma package in your browser

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

artma documentation built on Oct. 27, 2026, 9:06 a.m.