tests/testthat/test-bma.R

box::use(
  testthat[
    expect_equal,
    expect_error,
    expect_false,
    expect_length,
    expect_match,
    expect_message,
    expect_named,
    expect_null,
    expect_true,
    expect_type,
    skip_if_not_installed,
    test_that
  ],
  withr[local_options, local_tempdir]
)

box::use(
  artma / econometric / bma[
    get_bma_formula,
    handle_bma_params,
    get_bma_data,
    find_optimal_bma_formula,
    rename_bma_model,
    run_bma,
    unscale_bma_coefs
  ],
  artma / methods / bma[bma, prepare_bma_inputs],
  artma / options / template[get_option_defs]
)

make_demo_bma_data <- function() {
  set.seed(42)
  n_studies <- 10L
  per_study <- 5L
  study_ids <- rep(paste0("S", seq_len(n_studies)), each = per_study)

  data.frame(
    study_id = study_ids,
    effect = rnorm(n_studies * per_study, mean = 0.3, sd = 0.1),
    se = runif(n_studies * per_study, min = 0.05, max = 0.15),
    study_size = sample(20:80, n_studies * per_study, replace = TRUE),
    moderator1 = rnorm(n_studies * per_study, mean = 0, sd = 1),
    moderator2 = rbinom(n_studies * per_study, size = 1, prob = 0.5),
    moderator3 = rnorm(n_studies * per_study, mean = 5, sd = 2),
    check.names = FALSE,
    stringsAsFactors = FALSE
  )
}

test_that("get_bma_formula creates valid formula from variable vector", {
  df <- make_demo_bma_data()
  vars <- c("effect", "se", "moderator1", "moderator2")

  formula <- get_bma_formula(vars, df, get_var_vector_instead = FALSE)

  expect_true(inherits(formula, "formula"))
  expect_equal(all.vars(formula), c("effect", "se", "moderator1", "moderator2"))
})

test_that("get_bma_formula returns variable vector when requested", {
  df <- make_demo_bma_data()
  vars <- c("effect", "se", "moderator1", "moderator2")

  var_vector <- get_bma_formula(vars, df, get_var_vector_instead = TRUE)

  expect_type(var_vector, "character")
  expect_equal(var_vector, c("effect", "se", "moderator1", "moderator2"))
})

test_that("get_bma_formula removes constant variables", {
  df <- make_demo_bma_data()
  df$constant_var <- 1
  vars <- c("effect", "se", "moderator1", "constant_var")

  formula <- get_bma_formula(vars, df, get_var_vector_instead = FALSE)
  formula_vars <- all.vars(formula)

  expect_false("constant_var" %in% formula_vars)
  expect_true(all(c("effect", "se", "moderator1") %in% formula_vars))
})

test_that("handle_bma_params returns list of parameter lists", {
  params <- list(
    burn = 1000L,
    iter = 5000L,
    g = "UIP",
    mprior = "uniform"
  )

  result <- handle_bma_params(params)

  expect_type(result, "list")
  expect_length(result, 1)
  expect_equal(result[[1]]$burn, 1000L)
  expect_equal(result[[1]]$iter, 5000L)
})

test_that("handle_bma_params treats uniformly multi-valued parameters as multiple models", {
  params <- list(
    burn = c(100L, 200L),
    iter = c(500L, 1000L),
    g = c("UIP", "BRIC")
  )

  result <- handle_bma_params(params)

  expect_length(result, 2)
  expect_equal(result[[1]]$burn, 100L)
  expect_equal(result[[1]]$g, "UIP")
  expect_equal(result[[2]]$burn, 200L)
  expect_equal(result[[2]]$iter, 1000L)
  expect_equal(result[[2]]$g, "BRIC")
})

test_that("handle_bma_params aborts on mixed multi-value lengths", {
  params <- list(
    burn = c(100L, 200L),
    mprior = c("uniform", "random", "fixed"),
    g = "UIP"
  )

  expect_error(handle_bma_params(params), "1 or n values")
})

test_that("handle_bma_params splits multiple model configurations", {
  params <- list(
    burn = c(1000L, 2000L),
    iter = 5000L,
    g = "UIP",
    mprior = c("uniform", "random")
  )

  result <- handle_bma_params(params)

  expect_type(result, "list")
  expect_length(result, 2)
  expect_equal(result[[1]]$burn, 1000L)
  expect_equal(result[[2]]$burn, 2000L)
  expect_equal(result[[1]]$mprior, "uniform")
  expect_equal(result[[2]]$mprior, "random")
})

test_that("get_bma_data subsets and scales non-binary variables", {
  df <- make_demo_bma_data()
  var_list <- data.frame(
    var_name = c("effect", "se", "moderator1", "moderator2"),
    var_name_verbose = c("Effect", "SE", "Mod1", "Mod2"),
    bma = c(TRUE, TRUE, TRUE, TRUE),
    to_log_for_bma = c(FALSE, FALSE, FALSE, FALSE),
    bma_reference_var = c(FALSE, FALSE, FALSE, FALSE),
    stringsAsFactors = FALSE
  )
  variable_info <- c("effect", "se", "moderator1", "moderator2")

  bma_data <- get_bma_data(
    df,
    var_list,
    variable_info,
    scale_data = TRUE,
    from_vector = TRUE,
    include_reference_groups = FALSE
  )

  # Structure: the requested columns are subset out, one row per observation.
  expect_true(is.data.frame(bma_data))
  expect_equal(ncol(bma_data), 4)
  expect_equal(nrow(bma_data), nrow(df))
  expect_true("effect" %in% colnames(bma_data))

  # Scaling: the continuous moderator is centred and unit-scaled; the binary
  # moderator is left untouched.
  expect_true(abs(mean(bma_data$moderator1)) < 1e-10)
  expect_true(abs(sd(bma_data$moderator1) - 1) < 1e-10)
  expect_false(abs(mean(bma_data$moderator2)) < 1e-10)
})

test_that("unscale_bma_coefs inverts the z-scoring of slopes and intercept", {
  bma_data <- data.frame(effect = 1:4, x_cont = c(1, 3, 5, 9), x_bin = c(0, 1, 0, 1))
  attr(bma_data, "bpe_scale_centers") <- c(effect = 2.5, x_cont = 4.5, x_bin = 0)
  attr(bma_data, "bpe_scale_scales") <- c(effect = 2, x_cont = 4, x_bin = 1)

  coefs <- matrix(
    c(
      0.9, 0.5, 0.1, 1, 1,
      0.8, -0.25, 0.05, 0, 2,
      1, 0.3, NA, NA, 0
    ),
    nrow = 3, byrow = TRUE,
    dimnames = list(
      c("x_cont", "x_bin", "(Intercept)"),
      c("PIP", "Post Mean", "Post SD", "Cond.Pos.Sign", "Idx")
    )
  )

  unscaled <- unscale_bma_coefs(coefs, bma_data, c("x_cont", "x_bin"))

  # Slopes: b * sd(y) / sd(x); the dummy only picks up sd(y).
  expect_equal(unscaled["x_cont", "Post Mean"], 0.5 * 2 / 4)
  expect_equal(unscaled["x_cont", "Post SD"], 0.1 * 2 / 4)
  expect_equal(unscaled["x_bin", "Post Mean"], -0.25 * 2)
  expect_equal(unscaled["x_bin", "Post SD"], 0.05 * 2)
  # Intercept: mean(y) + sd(y) * a - sum(raw slope * mean(x)).
  expect_equal(unscaled["(Intercept)", "Post Mean"], 2.5 + 2 * 0.3 - 0.25 * 4.5)
  expect_true(is.na(unscaled["(Intercept)", "Post SD"]))
  # PIPs and the bookkeeping columns are untouched.
  expect_equal(unscaled[, c("PIP", "Cond.Pos.Sign", "Idx")], coefs[, c("PIP", "Cond.Pos.Sign", "Idx")])

  # A frame that was never scaled is passed through unchanged.
  expect_equal(unscale_bma_coefs(coefs, data.frame(effect = 1:4), c("x_cont", "x_bin")), coefs)
})

test_that("unscaled BMA coefficients match BMS fitted on the raw data", {
  skip_if_not_installed("BMS")
  local_options("artma.verbose" = 1)

  df <- make_demo_bma_data()
  vars <- c("effect", "se", "moderator1", "moderator2", "moderator3")
  var_list <- data.frame(
    var_name = vars,
    var_name_verbose = vars,
    bma = TRUE,
    to_log_for_bma = FALSE,
    bma_reference_var = FALSE,
    stringsAsFactors = FALSE
  )
  scaled <- get_bma_data(df, var_list, vars, scale_data = TRUE, from_vector = TRUE, include_reference_groups = FALSE)
  raw <- get_bma_data(df, var_list, vars, scale_data = FALSE, from_vector = TRUE, include_reference_groups = FALSE)

  # Enumerating the model space removes sampling noise, so the g-prior's scale
  # invariance makes the two fits agree to numerical precision.
  params <- list(burn = 10L, iter = 100L, nmodel = 16L, g = "UIP", mprior = "uniform", mcmc = "enumerate")
  coef_args <- list(order.by.pip = FALSE, exact = TRUE, include.constant = TRUE)

  scaled_model <- run_bma(scaled, params)
  raw_model <- run_bma(raw, params)
  scaled_coefs <- do.call(stats::coef, c(list(scaled_model), coef_args))
  raw_coefs <- do.call(stats::coef, c(list(raw_model), coef_args))

  unscaled <- unscale_bma_coefs(scaled_coefs, scaled, scaled_model$reg.names)

  expect_equal(rownames(unscaled), rownames(raw_coefs))
  expect_equal(unscaled[, "PIP"], raw_coefs[, "PIP"], tolerance = 1e-8)
  expect_equal(unscaled[, "Post Mean"], raw_coefs[, "Post Mean"], tolerance = 1e-8)
  slopes <- setdiff(rownames(unscaled), "(Intercept)")
  expect_equal(unscaled[slopes, "Post SD"], raw_coefs[slopes, "Post SD"], tolerance = 1e-8)
  # The test is only meaningful if the scaled fit differed from the raw one.
  expect_false(isTRUE(all.equal(scaled_coefs[slopes, "Post Mean"], raw_coefs[slopes, "Post Mean"])))
})

test_that("bma reports posterior means on the data scale", {
  skip_if_not_installed("BMS")

  df <- make_demo_bma_data()
  config <- list(
    effect = list(var_name = "effect", var_name_verbose = "Effect", bma = FALSE),
    se = list(var_name = "se", var_name_verbose = "SE", bma = TRUE),
    moderator1 = list(var_name = "moderator1", var_name_verbose = "Mod1", bma = TRUE),
    moderator3 = list(var_name = "moderator3", var_name_verbose = "Mod3", bma = TRUE)
  )

  local_options(list(
    artma.verbose = 0,
    artma.autonomy.level = "autonomous",
    artma.data.columns = config,
    artma.output.save_results = FALSE,
    artma.visualization.export_graphics = FALSE,
    artma.methods.bma.burn = 10L,
    artma.methods.bma.iter = 100L,
    artma.methods.bma.nmodel = 8L,
    artma.methods.bma.g = "UIP",
    artma.methods.bma.mprior = "uniform",
    artma.methods.bma.mcmc = "enumerate"
  ))

  result <- bma(df)

  raw <- df[c("effect", "se", "moderator1", "moderator3")]
  raw_model <- run_bma(raw, list(
    burn = 10L, iter = 100L, nmodel = 8L, g = "UIP", mprior = "uniform", mcmc = "enumerate"
  ))
  raw_coefs <- stats::coef(raw_model, order.by.pip = FALSE, exact = TRUE, include.constant = TRUE)

  # The model still ran on the z-scored frame ...
  expect_true(abs(mean(result$meta$data$effect)) < 1e-10)
  # ... but the reported numbers are on the data scale, intercept included.
  expected <- stats::setNames(raw_coefs[, "Post Mean"], c("SE", "Mod1", "Mod3", "Intercept"))
  reported <- stats::setNames(result$estimates$estimate, result$estimates$term)
  expect_equal(reported[names(expected)], expected, tolerance = 1e-8)
  expect_equal(result$tables$coefficients$post_mean, result$estimates$estimate)
  expect_equal(
    result$estimates$std_error[result$estimates$term != "Intercept"],
    unname(raw_coefs[rownames(raw_coefs) != "(Intercept)", "Post SD"]),
    tolerance = 1e-8
  )
})

test_that("run_bma executes without errors", {
  box::use(artma / econometric / bma[run_bma])

  df <- make_demo_bma_data()
  bma_data <- df[c("effect", "se", "moderator1", "moderator2")]

  params <- list(
    burn = 100L,
    iter = 500L,
    nmodel = 10L,
    g = "UIP",
    mprior = "uniform",
    mcmc = "bd"
  )

  local_options("artma.verbose" = 1)

  result <- run_bma(bma_data, params)

  expect_true(inherits(result, "bma"))
  expect_true(!is.null(result$topmod))
})

test_that("run_bma accepts the documented dilut model prior", {
  skip_if_not_installed("BMS")

  df <- make_demo_bma_data()
  bma_data <- df[c("effect", "se", "moderator1", "moderator2")]
  params <- list(
    burn = 10L,
    iter = 100L,
    nmodel = 8L,
    g = "UIP",
    mprior = "dilut",
    mcmc = "enumerate"
  )

  local_options("artma.verbose" = 1)

  dilut_model <- run_bma(bma_data, params)
  uniform_model <- run_bma(bma_data, utils::modifyList(params, list(mprior = "uniform")))

  expect_true(inherits(dilut_model, "bma"))
  expect_equal(dilut_model$mprior.info$origargs$mpmode, "dilut")
  # The dilution prior downweights collinear models, so the posterior
  # inclusion probabilities move away from the uniform ones.
  dilut_pip <- stats::coef(dilut_model, order.by.pip = FALSE)[, "PIP"]
  uniform_pip <- stats::coef(uniform_model, order.by.pip = FALSE)[, "PIP"]
  expect_false(isTRUE(all.equal(dilut_pip, uniform_pip)))
})

test_that("the mprior help lists every model prior artma passes to bms", {
  def <- get_option_defs(opt_path = "methods.bma.mprior")[[1]]

  for (prior in c("uniform", "random", "fixed", "dilut")) {
    expect_match(def$help, prior, fixed = TRUE)
  }
})

test_that("run_bma leaves the graphics device untouched", {
  # `BMS::bms` defaults to `user.int = TRUE`, which plots the fitted model on
  # the way out. In a forked method worker on macOS that plot opens quartz and
  # kills the child, so `run_bma()` must never let it draw.
  box::use(artma / econometric / bma[run_bma])

  df <- make_demo_bma_data()
  bma_data <- df[c("effect", "se", "moderator1", "moderator2")]

  params <- list(
    burn = 100L,
    iter = 500L,
    nmodel = 10L,
    g = "UIP",
    mprior = "uniform",
    mcmc = "bd"
  )

  local_options("artma.verbose" = 1)

  # Other tests in this file leave devices open, so start from a clean slate:
  # otherwise a device `bms` opens is indistinguishable from one already there.
  while (!is.null(grDevices::dev.list())) grDevices::dev.off()

  run_bma(bma_data, params)

  expect_null(grDevices::dev.list())
})

test_that("build_bma_model_labels labels only the varying parameters", {
  box::use(artma / econometric / bma[build_bma_model_labels])

  params_list <- list(
    list(burn = 100L, iter = 500L, g = "UIP", mprior = "uniform"),
    list(burn = 100L, iter = 500L, g = "BRIC", mprior = "uniform")
  )

  labels <- build_bma_model_labels(params_list)

  expect_equal(labels, c("g=UIP", "g=BRIC"))
})

test_that("build_bma_model_labels falls back to generic names when nothing varies", {
  box::use(artma / econometric / bma[build_bma_model_labels])

  params_list <- list(
    list(burn = 100L, g = "UIP"),
    list(burn = 100L, g = "UIP")
  )

  labels <- build_bma_model_labels(params_list)

  expect_equal(labels, c("Model 1", "Model 2"))
})

test_that("build_bma_model_labels returns a single label for one model", {
  box::use(artma / econometric / bma[build_bma_model_labels])

  labels <- build_bma_model_labels(list(list(burn = 100L, g = "UIP")))

  expect_equal(labels, "Model 1")
})

test_that("render_bma_comparison_plot exports a comparison png for multiple models", {
  skip_if_not_installed("BMS")
  box::use(
    artma / econometric / bma[run_bma, render_bma_comparison_plot]
  )

  df <- make_demo_bma_data()
  bma_data <- df[c("effect", "se", "moderator1", "moderator2")]

  params <- list(burn = 100L, iter = 500L, nmodel = 10L, g = "UIP", mprior = "uniform", mcmc = "bd")

  local_options("artma.verbose" = 1)

  model_1 <- run_bma(bma_data, params)
  model_2 <- run_bma(bma_data, utils::modifyList(params, list(mprior = "random")))

  export_dir <- local_tempdir()

  render_bma_comparison_plot(
    list(`mprior=uniform` = model_1, `mprior=random` = model_2),
    export_graphics = TRUE,
    export_path = export_dir
  )

  expect_true(file.exists(file.path(export_dir, "bma_comparison.png")))
})

test_that("bma exports a comparison plot when multiple parameter sets are run", {
  skip_if_not_installed("BMS")

  df <- make_demo_bma_data()
  config <- list(
    effect = list(var_name = "effect", var_name_verbose = "Effect", bma = FALSE),
    se = list(var_name = "se", var_name_verbose = "SE", bma = TRUE),
    moderator1 = list(var_name = "moderator1", var_name_verbose = "Mod1", bma = TRUE),
    moderator2 = list(var_name = "moderator2", var_name_verbose = "Mod2", bma = TRUE)
  )

  export_dir <- local_tempdir()

  local_options(list(
    artma.verbose = 0,
    artma.autonomy.level = "autonomous",
    artma.data.columns = config,
    artma.output.save_results = FALSE,
    artma.visualization.export_graphics = TRUE,
    artma.visualization.export_path = export_dir,
    artma.methods.bma.burn = 100L,
    artma.methods.bma.iter = 500L,
    artma.methods.bma.nmodel = 10L,
    artma.methods.bma.g = "UIP",
    artma.methods.bma.mprior = c("uniform", "random"),
    artma.methods.bma.mcmc = "bd"
  ))

  result <- bma(df)

  expect_length(result$meta$all, 2)
  expect_true(file.exists(file.path(export_dir, "bma_comparison.png")))
})

test_that("bma does not export a comparison plot for a single parameter set", {
  skip_if_not_installed("BMS")

  df <- make_demo_bma_data()
  config <- list(
    effect = list(var_name = "effect", var_name_verbose = "Effect", bma = FALSE),
    se = list(var_name = "se", var_name_verbose = "SE", bma = TRUE),
    moderator1 = list(var_name = "moderator1", var_name_verbose = "Mod1", bma = TRUE),
    moderator2 = list(var_name = "moderator2", var_name_verbose = "Mod2", bma = TRUE)
  )

  export_dir <- local_tempdir()

  local_options(list(
    artma.verbose = 0,
    artma.autonomy.level = "autonomous",
    artma.data.columns = config,
    artma.output.save_results = FALSE,
    artma.visualization.export_graphics = TRUE,
    artma.visualization.export_path = export_dir,
    artma.methods.bma.burn = 100L,
    artma.methods.bma.iter = 500L,
    artma.methods.bma.nmodel = 10L,
    artma.methods.bma.g = "UIP",
    artma.methods.bma.mprior = "uniform",
    artma.methods.bma.mcmc = "bd"
  ))

  result <- bma(df)

  expect_length(result$meta$all, 1)
  expect_false(file.exists(file.path(export_dir, "bma_comparison.png")))
})

test_that("prepare_bma_inputs skips gracefully when config selects a single moderator variable", {
  # BMS::bms() crashes with "subscript out of bounds" for a model space with
  # exactly one candidate regressor; a single selected moderator must be
  # caught here instead of reaching run_bma().
  df <- make_demo_bma_data()
  config <- list(
    effect = list(var_name = "effect", var_name_verbose = "Effect", bma = FALSE),
    se = list(var_name = "se", var_name_verbose = "SE", bma = TRUE)
  )

  prepared <- prepare_bma_inputs(
    df = df,
    config = config,
    use_vif_optimization = FALSE,
    max_groups_to_remove = 30L,
    scale_data = TRUE,
    verbosity = 0
  )

  expect_null(prepared$bma_data)
  expect_match(prepared$skipped, "at least 2 candidate moderator variables")
  expect_match(prepared$skipped, "se")
})

test_that("bma skips with an explanatory reason instead of crashing on a single moderator", {
  df <- make_demo_bma_data()
  config <- list(
    effect = list(var_name = "effect", var_name_verbose = "Effect", bma = FALSE),
    se = list(var_name = "se", var_name_verbose = "SE", bma = TRUE)
  )

  local_options(list(
    artma.verbose = 0,
    artma.autonomy.level = "autonomous",
    artma.data.columns = config,
    artma.output.save_results = FALSE
  ))

  result <- bma(df)

  expect_equal(nrow(result$tables$coefficients), 0)
  expect_true(is.null(result$meta$model))
  expect_match(result$meta$skip_reason, "at least 2 candidate moderator variables")
})

test_that("prepare_bma_inputs excludes constant moderators before scaling", {
  local_options(list("artma.verbose" = 1))

  df <- make_demo_bma_data()
  # A constant column (the shape an over-imputed, mostly-missing column takes)
  # would scale to all-NaN and let na.omit drop every observation.
  df$const_mod <- 1
  config <- list(
    se = list(var_name = "se", bma = TRUE),
    moderator1 = list(var_name = "moderator1", bma = TRUE),
    const_mod = list(var_name = "const_mod", bma = TRUE)
  )

  prepared <- prepare_bma_inputs(
    df, config,
    use_vif_optimization = FALSE,
    max_groups_to_remove = 3,
    verbosity = 1
  )

  expect_true(is.null(prepared$skipped))
  expect_false("const_mod" %in% colnames(prepared$bma_data))
  expect_true(all(c("effect", "se", "moderator1") %in% colnames(prepared$bma_data)))
  expect_true(nrow(prepared$bma_data) > 0)
})

make_collinear_pair_data <- function() {
  set.seed(7)
  n <- 100L
  x1 <- rnorm(n)
  data.frame(
    effect = rnorm(n),
    x1 = x1,
    x2 = x1 + rnorm(n, sd = 0.01),
    y1 = rnorm(n),
    y2 = rnorm(n),
    stringsAsFactors = FALSE
  )
}

make_ungrouped_var_list <- function(var_names) {
  data.frame(
    var_name = var_names,
    bma = TRUE,
    group_category = "other",
    stringsAsFactors = FALSE
  )
}

test_that("find_optimal_bma_formula aligns the constancy mask with var-list rows", {
  skip_if_not_installed("car")

  set.seed(11)
  n <- 60L
  # Data columns are deliberately ordered differently from the var-list rows,
  # with the constant column first, so a mask computed in data order would
  # pair with the wrong rows.
  df <- data.frame(
    const_var = 1,
    effect = rnorm(n),
    mod2 = rnorm(n),
    mod1 = rnorm(n),
    stringsAsFactors = FALSE
  )
  var_list <- make_ungrouped_var_list(c("effect", "mod1", "mod2", "const_var"))

  result <- find_optimal_bma_formula(
    df,
    var_list,
    return_variable_vector_instead = TRUE,
    verbose = FALSE
  )

  expect_equal(result$result, c("mod1", "mod2"))
  expect_equal(result$removed_groups, 0)
})

test_that("find_optimal_bma_formula removes 'other' variables one at a time", {
  skip_if_not_installed("car")

  df <- make_collinear_pair_data()
  var_list <- make_ungrouped_var_list(c("effect", "x1", "x2", "y1", "y2"))

  result <- find_optimal_bma_formula(
    df,
    var_list,
    return_variable_vector_instead = TRUE,
    verbose = FALSE
  )

  # Only one member of the collinear pair is dropped; the unrelated
  # moderators sharing the "other" category survive.
  expect_length(result$result, 3)
  expect_true(all(c("y1", "y2") %in% result$result))
  expect_length(result$removed_groups_verbose, 1)
})

test_that("find_optimal_bma_formula still removes genuine dummy groups wholesale", {
  skip_if_not_installed("car")

  set.seed(13)
  n <- 120L
  # Near dummy trap: the three region dummies cover all but one observation,
  # so their VIFs blow up without the model becoming aliased.
  category <- rep(c("a", "b", "c", "base"), times = c(55L, 56L, 8L, 1L))
  df <- data.frame(
    effect = rnorm(n),
    reg_a = as.integer(category == "a"),
    reg_b = as.integer(category == "b"),
    reg_c = as.integer(category == "c"),
    y1 = rnorm(n),
    y2 = rnorm(n),
    stringsAsFactors = FALSE
  )
  var_list <- data.frame(
    var_name = c("effect", "reg_a", "reg_b", "reg_c", "y1", "y2"),
    bma = TRUE,
    group_category = c("other", "region", "region", "region", "other", "other"),
    stringsAsFactors = FALSE
  )

  result <- find_optimal_bma_formula(
    df,
    var_list,
    return_variable_vector_instead = TRUE,
    verbose = FALSE
  )

  expect_equal(result$result, c("y1", "y2"))
  expect_equal(result$removed_groups, 1)
  expect_equal(sort(result$removed_groups_verbose), c("reg_a", "reg_b", "reg_c"))
})

test_that("find_optimal_bma_formula succeeds when the last allowed removal fixes VIF", {
  skip_if_not_installed("car")

  df <- make_collinear_pair_data()
  var_list <- make_ungrouped_var_list(c("effect", "x1", "x2", "y1", "y2"))

  result <- find_optimal_bma_formula(
    df,
    var_list,
    max_groups_to_remove = 1,
    return_variable_vector_instead = TRUE,
    verbose = FALSE
  )

  expect_equal(result$removed_groups, 1)
  expect_true(all(c("y1", "y2") %in% result$result))
})

test_that("find_optimal_bma_formula aborts only when removals are exhausted and VIFs remain high", {
  skip_if_not_installed("car")

  set.seed(17)
  n <- 100L
  x1 <- rnorm(n)
  z1 <- rnorm(n)
  df <- data.frame(
    effect = rnorm(n),
    x1 = x1,
    x2 = x1 + rnorm(n, sd = 0.01),
    z1 = z1,
    z2 = z1 + rnorm(n, sd = 0.01),
    y1 = rnorm(n),
    stringsAsFactors = FALSE
  )
  var_list <- make_ungrouped_var_list(c("effect", "x1", "x2", "z1", "z2", "y1"))

  expect_error(
    find_optimal_bma_formula(
      df,
      var_list,
      max_groups_to_remove = 1,
      return_variable_vector_instead = TRUE,
      verbose = FALSE
    ),
    "Optimal BMA formula not found"
  )
})

test_that("rename_bma_model leaves unmatched regressor names untouched", {
  model <- structure(
    list(reg.names = c("mod1", "not_in_list")),
    class = "bma"
  )
  var_list <- data.frame(
    var_name = c("effect", "mod1"),
    var_name_verbose = c("Effect", "Moderator 1"),
    stringsAsFactors = FALSE
  )

  renamed <- rename_bma_model(model, var_list)

  expect_equal(renamed$reg.names, c("Moderator 1", "not_in_list"))
})

test_that("prepare_bma_inputs drops non-numeric moderators instead of dying inside scale()", {
  local_options(list("artma.verbose" = 1))

  # A character moderator used to reach `scale()`, which aborts with a bare
  # "'x' must be numeric" naming no column.
  df <- make_demo_bma_data()
  df$reg_num <- sample(c("a", "b", "c"), nrow(df), replace = TRUE)
  config <- list(
    moderator1 = list(var_name = "moderator1", bma = TRUE),
    moderator2 = list(var_name = "moderator2", bma = TRUE),
    reg_num = list(var_name = "reg_num", bma = TRUE)
  )

  expect_message(
    prepared <- prepare_bma_inputs(
      df, config,
      use_vif_optimization = FALSE,
      max_groups_to_remove = 3,
      verbosity = 2
    ),
    "reg_num"
  )

  expect_null(prepared$skipped)
  expect_false("reg_num" %in% colnames(prepared$bma_data))
  expect_true(all(vapply(prepared$bma_data, is.numeric, logical(1))))
})

test_that("prepare_bma_inputs keeps logical moderators, which scale() handles", {
  local_options(list("artma.verbose" = 1))

  df <- make_demo_bma_data()
  df$flag_mod <- rep(c(TRUE, FALSE), length.out = nrow(df))
  config <- list(
    moderator1 = list(var_name = "moderator1", bma = TRUE),
    flag_mod = list(var_name = "flag_mod", bma = TRUE)
  )

  prepared <- prepare_bma_inputs(
    df, config,
    use_vif_optimization = FALSE,
    max_groups_to_remove = 3,
    verbosity = 0
  )

  expect_true("flag_mod" %in% colnames(prepared$bma_data))
})

test_that("prepare_bma_inputs warns about the excluded constant moderator", {
  local_options(list("artma.verbose" = 1))

  df <- make_demo_bma_data()
  df$const_mod <- 1
  config <- list(
    moderator1 = list(var_name = "moderator1", bma = TRUE),
    const_mod = list(var_name = "const_mod", bma = TRUE)
  )

  expect_message(
    prepare_bma_inputs(
      df, config,
      use_vif_optimization = FALSE,
      max_groups_to_remove = 3,
      verbosity = 2
    ),
    "constant"
  )
})

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.