tests/testthat/test-method-estimates-contract.R

box::use(
  testthat[
    expect_equal,
    expect_length,
    expect_named,
    expect_setequal,
    expect_true,
    test_that
  ]
)

box::use(
  artma / modules / runtime_methods[ESTIMATES_COLUMNS],
  artma / paths[PATHS]
)

# Methods that produce graphics only. They have no numbers to report, so they
# leave `estimates` unset; everything else must fill it.
PLOT_ONLY_METHODS <- c(
  "box_plot",
  "funnel_plot",
  "prima_facie_graphs",
  "t_stat_histogram"
)

discovered_method_names <- function() {
  files <- list.files(PATHS$DIR_METHODS, pattern = "[.][Rr]$", full.names = FALSE)
  sort(tools::file_path_sans_ext(files))
}

# TRUE when any `new_method_result()` call in the file passes an `estimates`
# argument. Walking the parse tree, rather than grepping, keeps a method that
# merely mentions the word from passing the check.
wires_estimates <- function(path) {
  found <- FALSE

  # An empty argument (`df[i, ]`) is the empty symbol, which errors the moment
  # it is evaluated, so every child is forced behind a guard.
  is_empty_arg <- function(x) {
    tryCatch(
      {
        force(x)
        FALSE
      },
      error = function(e) TRUE
    )
  }

  walk <- function(node) {
    if (found) {
      return(invisible(NULL))
    }
    if (is.call(node)) {
      target <- node[[1L]]
      if (is.name(target) && identical(as.character(target), "new_method_result") &&
        "estimates" %in% names(node)) {
        found <<- TRUE
        return(invisible(NULL))
      }
    }
    if (is.call(node) || is.pairlist(node) || is.expression(node)) {
      for (child in as.list(node)) {
        if (is_empty_arg(child)) next
        walk(child)
      }
    }
    invisible(NULL)
  }

  walk(parse(path, keep.source = FALSE))
  found
}

test_that("the plot-only allowlist names methods that actually exist", {
  expect_true(all(PLOT_ONLY_METHODS %in% discovered_method_names()))
})

test_that("every runtime method either reports estimates or is plot-only", {
  method_names <- discovered_method_names()
  expect_true(length(method_names) > 0L)

  reporting <- setdiff(method_names, PLOT_ONLY_METHODS)
  missing <- Filter(
    function(name) !wires_estimates(file.path(PATHS$DIR_METHODS, paste0(name, ".R"))),
    reporting
  )

  # A new method that only builds a display table trips this: either fill the
  # `estimates` slot or declare the method plot-only above.
  expect_equal(as.character(missing), character())
})

# Per-method estimates builders ---------------------------------------------

expect_schema <- function(estimates, expected_method) {
  expect_named(estimates, ESTIMATES_COLUMNS)
  expect_true(is.numeric(estimates$estimate))
  expect_true(is.integer(estimates$n_obs))
  expect_true(is.character(estimates$note))
  if (nrow(estimates) > 0L) {
    expect_setequal(unique(estimates$method), expected_method)
  }
  invisible(estimates)
}

test_that("linear_tests_estimates conforms to the schema and stays unrounded", {
  box::use(artma / methods / linear_tests[linear_tests_estimates])

  estimates <- linear_tests_estimates(data.frame(
    model = c("ols", "ols"),
    term = c("effect", "publication_bias"),
    estimate = c(0.0571234, -0.1289876),
    std_error = c(0.0123456, 0.0234567),
    statistic = c(4.63, -5.5),
    p_value = c(0.0000123, 0.0000456),
    bootstrap_lower = c(0.03, -0.2),
    bootstrap_upper = c(0.08, -0.05),
    n_obs = c(120L, 120L),
    ci_conflict = c(FALSE, TRUE),
    stringsAsFactors = FALSE
  ))

  expect_schema(estimates, "linear_tests")
  expect_equal(estimates$estimate, c(0.0571234, -0.1289876))
  expect_true(is.na(estimates$note[[1L]]))
  expect_true(nzchar(estimates$note[[2L]]))
})

test_that("nonlinear_tests_estimates maps the per-model coefficient frame", {
  box::use(artma / methods / nonlinear_tests[nonlinear_tests_estimates])

  estimates <- nonlinear_tests_estimates(data.frame(
    model = c("waap", "waap"),
    term = c("publication_bias", "effect"),
    estimate = c(0.2345678, 0.0456789),
    std_error = c(0.01, 0.02),
    p_value = c(0.01, 0.2),
    n_obs_total = c(200L, 200L),
    n_obs_model = c(84L, 84L),
    stringsAsFactors = FALSE
  ))

  expect_schema(estimates, "nonlinear_tests")
  expect_equal(estimates$n_obs, c(84L, 84L))
  expect_equal(estimates$estimate, c(0.2345678, 0.0456789))

  expect_equal(nrow(nonlinear_tests_estimates(data.frame())), 0L)
})

test_that("exogeneity_tests_estimates splits the two estimators into models", {
  box::use(artma / methods / exogeneity_tests[exogeneity_tests_estimates])

  coefficients <- function() {
    data.frame(
      term = c("effect", "publication_bias"),
      estimate = c(0.11, 0.22),
      std_error = c(0.01, 0.02),
      statistic = c(11, 11),
      p_value = c(0.001, 0.002),
      n_obs = c(50L, 50L),
      stringsAsFactors = FALSE
    )
  }

  estimates <- exogeneity_tests_estimates(list(
    iv = list(coefficients = coefficients(), weak_instrument = TRUE),
    puniform = list(coefficients = coefficients(), note = "did not converge")
  ))

  expect_schema(estimates, "exogeneity_tests")
  expect_equal(nrow(estimates), 4L)
  expect_setequal(unique(estimates$model), c("iv", "puniform"))
  expect_true(all(grepl("weak", estimates$note[estimates$model == "iv"])))
  expect_true(all(estimates$note[estimates$model == "puniform"] == "did not converge"))

  expect_equal(nrow(exogeneity_tests_estimates(list())), 0L)
})

test_that("p_hacking_tests_estimates maps thresholds to models and widths to terms", {
  box::use(artma / methods / p_hacking_tests[p_hacking_tests_estimates])

  estimates <- p_hacking_tests_estimates(list(
    caliper_results = list(
      list(
        threshold = 1.96, width = 0.05, share_above = 0.6666667, p_value = 0.0412345,
        n_above = 6, n_below = 3, n_studies = 4L, direction = "above",
        tail = "positive", cluster_method = "study"
      )
    ),
    elliott_results = list(
      binomial_005 = list(
        test = "Binomial [0, 0.05]", family = "binomial",
        support = "[0, 0.05]", p_value = 0.0123456
      ),
      lcm_005 = list(
        test = "LCM [0, 0.05]", family = "lcm", support = "[0, 0.05]",
        p_value = structure(NA_real_, reason = "package 'fdrtool' is not installed")
      )
    )
  ))

  expect_schema(estimates, "p_hacking_tests")
  expect_equal(nrow(estimates), 3L)

  caliper <- estimates[estimates$model == "threshold_1.96", ]
  expect_equal(caliper$term, "width_0.05")
  expect_equal(caliper$estimate, 0.6666667)
  expect_equal(caliper$p_value, 0.0412345)
  expect_equal(caliper$n_obs, 9L)
  expect_equal(caliper$n_clusters, 4L)

  lcm <- estimates[estimates$model == "lcm", ]
  expect_true(is.na(lcm$p_value))
  expect_true(grepl("fdrtool", lcm$note))

  expect_equal(nrow(p_hacking_tests_estimates(list())), 0L)
})

test_that("variable_summary_stats_estimates reports missingness as a proportion", {
  box::use(artma / methods / variable_summary_stats[variable_summary_stats_estimates])

  estimates <- variable_summary_stats_estimates(list(
    list(
      label = "Study year", mean = 2005.4567, median = 2006, min = 1998,
      max = 2019, sd = 4.98765, missing_share = 0.125, obs = 56L
    )
  ))

  expect_schema(estimates, "variable_summary_stats")
  expect_equal(estimates$model, rep("Study year", 6L))
  expect_setequal(
    estimates$term,
    c("mean", "median", "min", "max", "sd", "missing_share")
  )
  expect_equal(estimates$estimate[estimates$term == "missing_share"], 0.125)
  expect_equal(estimates$estimate[estimates$term == "mean"], 2005.4567)

  expect_equal(nrow(variable_summary_stats_estimates(list())), 0L)
})

test_that("effect_summary_stats_estimates keeps each mean with its own interval", {
  box::use(artma / methods / effect_summary_stats[effect_summary_stats_estimates])

  estimates <- effect_summary_stats_estimates(list(
    `All Data` = list(
      label = "All Data",
      unweighted = list(
        mean = 0.1234567, sd = 0.5, ci = c(0.05, 0.19),
        median = 0.1, min = -1.2, max = 2.4, obs = 300L
      ),
      weighted = list(mean = 0.0987654, ci = c(0.02, 0.18))
    )
  ))

  expect_schema(estimates, "effect_summary_stats")
  expect_equal(nrow(estimates), 6L)
  expect_equal(estimates$estimate[estimates$term == "mean"], 0.1234567)
  expect_equal(estimates$conf_low[estimates$term == "weighted_mean"], 0.02)
  expect_true(all(is.na(estimates$conf_low[estimates$term %in% c("median", "min", "max", "sd")])))
  expect_equal(unique(estimates$n_obs), 300L)

  expect_equal(nrow(effect_summary_stats_estimates(list())), 0L)
})

test_that("bma_estimates puts the posterior inclusion probability in statistic", {
  box::use(artma / methods / bma[bma_estimates])

  estimates <- bma_estimates(
    data.frame(
      variable = c("Intercept", "Precision"),
      pip = c(0.5512345, 0.9987654),
      post_mean = c(0.0123456, -0.2034567),
      post_sd = c(0.05, 0.023),
      cond_pos_sign = c(0.6, 0),
      stringsAsFactors = FALSE
    ),
    n_obs = 412L
  )

  expect_schema(estimates, "bma")
  expect_equal(estimates$statistic, c(0.5512345, 0.9987654))
  expect_equal(estimates$estimate, c(0.0123456, -0.2034567))
  expect_equal(unique(estimates$n_obs), 412L)
  expect_true(all(grepl("PIP", estimates$note)))
})

test_that("fma_estimates fills n_clusters only when the SEs are clustered", {
  box::use(artma / methods / fma[fma_estimates])

  coefficients <- data.frame(
    variable = c("Intercept", "Precision"),
    coefficient = c(-3.6741234, 0.2881234),
    se = c(0.888, 0.011),
    p_value = c(0.0012345, 0.0000123),
    stringsAsFactors = FALSE
  )

  clustered <- fma_estimates(coefficients, n_obs = 412L, n_clusters = 37L)
  expect_schema(clustered, "fma")
  expect_equal(unique(clustered$n_clusters), 37L)
  expect_equal(clustered$estimate, c(-3.6741234, 0.2881234))

  iid <- fma_estimates(coefficients, n_obs = 412L)
  expect_true(all(is.na(iid$n_clusters)))
})

test_that("robma_estimates carries the credible interval bounds", {
  box::use(artma / methods / robma[robma_estimates])

  tbl <- data.frame(
    Mean = c(0.1234567, 0.0456789),
    Median = c(0.12, 0.04),
    `0.025` = c(0.01, 0.001),
    `0.975` = c(0.25, 0.09),
    check.names = FALSE
  )
  rownames(tbl) <- c("mu", "tau")

  estimates <- robma_estimates(tbl, n_obs = 88L)

  expect_schema(estimates, "robma")
  expect_equal(estimates$term, c("mu", "tau"))
  expect_equal(estimates$estimate, c(0.1234567, 0.0456789))
  expect_equal(estimates$conf_low, c(0.01, 0.001))
  expect_equal(estimates$conf_high, c(0.25, 0.09))

  expect_equal(nrow(robma_estimates(NULL)), 0L)
})

test_that("maive_estimates splits the corrected and unadjusted models", {
  box::use(artma / methods / maive[maive_estimates])

  estimates <- maive_estimates(
    list(
      beta = 0.1234567,
      SE = 0.0456789,
      egger_coef = 1.2345678,
      egger_se = 0.3456789,
      `pub bias p-value` = 0.0004321,
      beta_standard = 0.2345678,
      SE_standard = 0.0345678,
      `F-test` = 24.6813579
    ),
    n_obs = 150L
  )

  expect_schema(estimates, "maive")
  expect_setequal(unique(estimates$model), c("maive", "unadjusted"))

  effect <- estimates[estimates$model == "maive" & estimates$term == "effect", ]
  expect_equal(effect$estimate, 0.1234567)
  expect_true(effect$conf_low < effect$estimate && effect$conf_high > effect$estimate)

  f_row <- estimates[estimates$term == "first_stage_f", ]
  expect_length(f_row$statistic, 1L)
  expect_equal(f_row$statistic, 24.6813579)
  expect_true(is.na(f_row$estimate))

  expect_equal(nrow(maive_estimates(NULL)), 0L)
})

test_that("bpe_estimates maps each scope to a model", {
  box::use(artma / methods / best_practice_estimate[bpe_estimates])

  estimates <- bpe_estimates(
    data.frame(
      scope = c("author", "study"),
      study_id = c(NA_character_, "s1"),
      study_label = c("Author", "Smith (2019)"),
      estimate = c(0.1234567, 0.2345678),
      standard_error = c(0.0123456, 0.0234567),
      ci_lower = c(0.09, 0.19),
      ci_upper = c(0.16, 0.28),
      stringsAsFactors = FALSE
    ),
    n_clusters = 42L
  )

  expect_schema(estimates, "best_practice_estimate")
  expect_equal(estimates$model, c("author", "study"))
  expect_equal(estimates$term, c("Author", "Smith (2019)"))
  expect_equal(estimates$estimate, c(0.1234567, 0.2345678))
  expect_equal(unique(estimates$n_clusters), 42L)

  expect_equal(nrow(bpe_estimates(data.frame())), 0L)
})

# Export round trip ---------------------------------------------------------

# One representative frame per reporting method, built by that method's own
# estimates builder. Every fixture value carries seven decimals, so a rounded
# export shows up as a changed number rather than a formatting nicety.
sample_estimates <- function() {
  box::use(
    artma / methods / best_practice_estimate[bpe_estimates],
    artma / methods / bma[bma_estimates],
    artma / methods / effect_summary_stats[effect_summary_stats_estimates],
    artma / methods / exogeneity_tests[exogeneity_tests_estimates],
    artma / methods / fma[fma_estimates],
    artma / methods / linear_tests[linear_tests_estimates],
    artma / methods / maive[maive_estimates],
    artma / methods / nonlinear_tests[nonlinear_tests_estimates],
    artma / methods / p_hacking_tests[p_hacking_tests_estimates],
    artma / methods / robma[robma_estimates],
    artma / methods / variable_summary_stats[variable_summary_stats_estimates]
  )

  robma_table <- data.frame(
    Mean = 0.1234567, Median = 0.12, `0.025` = 0.0123456, `0.975` = 0.2534567,
    check.names = FALSE
  )
  rownames(robma_table) <- "mu"

  list(
    best_practice_estimate = bpe_estimates(data.frame(
      scope = "author", study_id = NA_character_, study_label = "Author",
      estimate = 0.1234567, standard_error = 0.0123456,
      ci_lower = 0.0912345, ci_upper = 0.1612345, stringsAsFactors = FALSE
    )),
    bma = bma_estimates(data.frame(
      variable = "Precision", pip = 0.9987654, post_mean = -0.2034567,
      post_sd = 0.0234567, stringsAsFactors = FALSE
    )),
    effect_summary_stats = effect_summary_stats_estimates(list(list(
      label = "All Data",
      unweighted = list(
        mean = 0.1234567, sd = 0.5123456, ci = c(0.0512345, 0.1912345),
        median = 0.1023456, min = -1.2034567, max = 2.4056789, obs = 300L
      ),
      weighted = list(mean = 0.0987654, ci = c(0.0212345, 0.1812345))
    ))),
    exogeneity_tests = exogeneity_tests_estimates(list(
      iv = list(
        coefficients = data.frame(
          term = "effect", estimate = 0.1123456, std_error = 0.0212345,
          statistic = 5.2876543, p_value = 0.0012345, n_obs = 50L,
          stringsAsFactors = FALSE
        ),
        weak_instrument = FALSE
      )
    )),
    fma = fma_estimates(data.frame(
      variable = "Precision", coefficient = 0.2881234, se = 0.0112345,
      p_value = 0.0000123, stringsAsFactors = FALSE
    )),
    linear_tests = linear_tests_estimates(data.frame(
      model = "ols", term = "effect", estimate = 0.0571234,
      std_error = 0.0123456, statistic = 4.6271234, p_value = 0.0000123,
      bootstrap_lower = 0.0312345, bootstrap_upper = 0.0812345,
      n_obs = 120L, ci_conflict = FALSE, stringsAsFactors = FALSE
    )),
    maive = maive_estimates(list(
      beta = 0.1234567, SE = 0.0456789, egger_coef = 1.2345678,
      egger_se = 0.3456789, `pub bias p-value` = 0.0004321
    )),
    nonlinear_tests = nonlinear_tests_estimates(data.frame(
      model = "waap", term = "effect", estimate = 0.0456789,
      std_error = 0.0212345, p_value = 0.2012345, n_obs_model = 84L,
      stringsAsFactors = FALSE
    )),
    p_hacking_tests = p_hacking_tests_estimates(list(caliper_results = list(list(
      threshold = 1.96, width = 0.05, share_above = 0.6666667,
      p_value = 0.0412345, n_above = 6, n_below = 3, n_studies = 4L,
      direction = "above", tail = "positive", cluster_method = "study"
    )))),
    robma = robma_estimates(robma_table),
    variable_summary_stats = variable_summary_stats_estimates(list(list(
      label = "Study year", mean = 2005.4567891, median = 2006, min = 1998,
      max = 2019, sd = 4.9876543, missing_share = 0.1256789, obs = 56L
    )))
  )
}

test_that("every method's exported estimates CSV parses as unrounded numerics", {
  box::use(
    artma / output / export[ensure_output_dirs, export_results],
    withr[local_options, local_tempdir]
  )

  dir <- local_tempdir()
  local_options(
    artma.output.dir = dir,
    artma.output.number_of_decimals = 3,
    artma.output.table_formats = "csv",
    artma.visualization.export_path = "graphics",
    artma.verbose = 1
  )
  ensure_output_dirs(dir)

  frames <- sample_estimates()
  results <- lapply(frames, function(estimates) list(tables = list(), estimates = estimates))
  export_results(results, dir)

  for (method_name in names(frames)) {
    path <- file.path(dir, "tables", paste0(method_name, ".csv"))
    expect_true(file.exists(path))

    written <- utils::read.csv(path, stringsAsFactors = FALSE)
    for (column in c("estimate", "std_error", "statistic", "p_value", "conf_low", "conf_high")) {
      values <- written[[column]]
      expect_true(is.numeric(values) || all(is.na(values)))
    }

    # Every fixture carries more precision than the display rounding, so a
    # value that survives rounding unchanged means the method pre-rounded it.
    numbers <- unlist(written[c("estimate", "std_error", "p_value")])
    numbers <- numbers[is.finite(numbers) & numbers != 0]
    expect_true(any(numbers != round(numbers, 3)))
  }
})

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.