# tests/testthat/test-searchOptimalConfiguration.R testthat::skip_on_cran() # ---- dependency guard -------------------------------------------------------- skip_if_missing_deps <- function() { testthat::skip_if_not_installed("ape") testthat::skip_if_not_installed("phytools") testthat::skip_if_not_installed("mvMORPH") testthat::skip_if_not_installed("future") } # ---- locate and load fixture ------------------------------------------------- load_simdata_fixture <- function() { # Expect the file at tests/testthat/fixtures/simdata.RDS rds_path <- testthat::test_path("fixtures", "simdata.RDS") if (!file.exists(rds_path)) { testthat::skip(paste("Fixture not found:", rds_path)) } obj <- readRDS(rds_path) # Some fixtures are a list-of-lists; unwrap first element if needed bundle <- if (is.list(obj) && !all(c("paintedTree", "simulatedData") %in% names(obj))) obj[[1]] else obj # Basic sanity if (!all(c("paintedTree", "simulatedData") %in% names(bundle))) { testthat::skip("Fixture does not contain expected names: paintedTree, simulatedData") } bundle } # ---- helper to build 100-tip baseline + aligned data ------------------------- build_baseline_and_data <- function(simdata) { # Coerce paintedTree to phylo if needed tree0 <- if (inherits(simdata$paintedTree, "phylo")) simdata$paintedTree else ape::as.phylo(simdata$paintedTree) # Subsample 100 tips (for speed and determinism) set.seed(123) keep <- sample(tree0$tip.label, size = 100) tree0 <- ape::drop.tip(tree0, setdiff(tree0$tip.label, keep)) # Paint a global baseline state "0" from the root # root <- ape::Ntip(tree0) + 1L # baseline <- phytools::paintSubTree( # tree = tree0, # node = root, # state = "0", # anc.state = "0", # stem = FALSE # ) baseline <- as.phylo(tree0) # Align trait data order to tree tips X <- simdata$simulatedData testthat::expect_true(is.matrix(X) || is.data.frame(X)) testthat::expect_true(all(baseline$tip.label %in% rownames(X))) X <- X[baseline$tip.label, , drop = FALSE] list(tree = baseline, X = X) } # ---- tiny helpers for tolerant assertions ------------------------------------ expect_phylo_or_null <- function(x) { testthat::expect_true(is.null(x) || inherits(x, "phylo")) } expect_numeric_scalar <- function(x) { testthat::expect_true(is.numeric(x) && length(x) == 1L && is.finite(x)) } # Group: end-to-end runs and core outputs # Test: searchOptimalConfiguration runs end-to-end on simulated data (GIC) (fixture simdata.RDS; 100-tip subsample; GIC path) test_that("searchOptimalConfiguration runs end-to-end on simulated data (GIC)", { skip_if_missing_deps() simdata <- load_simdata_fixture() built <- build_baseline_and_data(simdata) baseline <- built$tree X <- built$X set.seed(123) res <- searchOptimalConfiguration( baseline_tree = baseline, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 10, num_cores = 1, shift_acceptance_threshold = 5, plot = FALSE, IC = "GIC", store_model_fit_history = FALSE, method = "LL", uncertaintyweights = TRUE ) # Core structure checks (present names) testthat::expect_type(res, "list") testthat::expect_true(all(c( "tree_no_uncertainty_transformed", "tree_no_uncertainty_untransformed", "model_no_uncertainty", "shift_nodes_no_uncertainty", "optimal_ic", "baseline_ic", "IC_used", "num_candidates" ) %in% names(res))) # Types/values expect_numeric_scalar(res$baseline_ic) expect_numeric_scalar(res$optimal_ic) testthat::expect_true(res$IC_used %in% c("GIC", "BIC")) # These may be NULL if no shifts are accepted; otherwise phylo expect_phylo_or_null(res$tree_no_uncertainty_untransformed) expect_phylo_or_null(res$tree_no_uncertainty_transformed) testthat::expect_true(is.list(res$VCVs) || is.null(res$VCVs)) # If shifts were accepted, best should improve or match baseline if (length(res$shift_nodes_no_uncertainty) > 0) { testthat::expect_lte(res$optimal_ic, res$baseline_ic) } else { # No shifts: optimal equals baseline (within tiny tolerance) testthat::expect_equal(res$optimal_ic, res$baseline_ic, tolerance = 1e-8) } }) # Test: searchOptimalConfiguration runs end-to-end on simulated data (BIC) (fixture simdata.RDS; 100-tip subsample; BIC path) test_that("searchOptimalConfiguration runs end-to-end on simulated data (BIC)", { skip_if_missing_deps() simdata <- load_simdata_fixture() built <- build_baseline_and_data(simdata) baseline <- built$tree X <- built$X set.seed(123) res <- searchOptimalConfiguration( baseline_tree = baseline, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 10, num_cores = 1, shift_acceptance_threshold = 5, plot = FALSE, IC = "BIC", store_model_fit_history = TRUE, method = "LL", uncertaintyweights_par = TRUE ) # Core structure checks (present names) testthat::expect_type(res, "list") testthat::expect_true(all(c( "tree_no_uncertainty_transformed", "tree_no_uncertainty_untransformed", "model_no_uncertainty", "shift_nodes_no_uncertainty", "optimal_ic", "baseline_ic", "IC_used", "num_candidates" ) %in% names(res))) # Types/values expect_numeric_scalar(res$baseline_ic) expect_numeric_scalar(res$optimal_ic) testthat::expect_true(res$IC_used %in% c("GIC", "BIC")) # These may be NULL if no shifts are accepted; otherwise phylo expect_phylo_or_null(res$tree_no_uncertainty_untransformed) expect_phylo_or_null(res$tree_no_uncertainty_transformed) testthat::expect_true(is.list(res$VCVs) || is.null(res$VCVs)) # If shifts were accepted, best should improve or match baseline if (length(res$shift_nodes_no_uncertainty) > 0) { testthat::expect_lte(res$optimal_ic, res$baseline_ic) } else { # No shifts: optimal equals baseline (within tiny tolerance) testthat::expect_equal(res$optimal_ic, res$baseline_ic, tolerance = 1e-8) } }) # Group: ic_weights correctness # Test: ic_weights are internally consistent when present (rtree(40) with threshold=-Inf; checks delta/evidence_ratio) test_that("ic_weights are internally consistent when present", { skip_if_missing_deps() set.seed(123) tr <- ape::rtree(40) X <- matrix(rnorm(40 * 2), ncol = 2) rownames(X) <- tr$tip.label res <- searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 5, num_cores = 1, shift_acceptance_threshold = -Inf, # encourage accepting shifts plot = FALSE, store_model_fit_history = FALSE, method = "LL", verbose = FALSE, IC = "GIC", uncertaintyweights_par = TRUE ) testthat::expect_true("ic_weights" %in% names(res)) testthat::expect_true(is.data.frame(res$ic_weights)) if (nrow(res$ic_weights) > 0) { # delta_ic should equal ic_with_shift - ic_without_shift testthat::expect_equal( res$ic_weights$delta_ic, res$ic_weights$ic_with_shift - res$ic_weights$ic_without_shift, tolerance = 1e-10 ) # evidence_ratio should equal weight_with / weight_without testthat::expect_equal( res$ic_weights$evidence_ratio, res$ic_weights$ic_weight_withshift / res$ic_weights$ic_weight_withoutshift, tolerance = 1e-10 ) } }) # Group: execution modes and verbosity # Test: searchOptimalConfiguration also runs in purely sequential mode (forces future::sequential plan) test_that("searchOptimalConfiguration also runs in purely sequential mode", { skip_if_missing_deps() simdata <- load_simdata_fixture() built <- build_baseline_and_data(simdata) baseline <- built$tree X <- built$X # Force a sequential future plan for the duration of this test old_plan <- NULL if (requireNamespace("future", quietly = TRUE)) { old_plan <- future::plan() future::plan(future::sequential) } on.exit({ if (!is.null(old_plan)) future::plan(old_plan) }, add = TRUE) set.seed(456) res <- searchOptimalConfiguration( baseline_tree = baseline, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 10, num_cores = 1, # hint to not parallelize shift_acceptance_threshold = 5, plot = FALSE, IC = "GIC", store_model_fit_history = FALSE, method = "LL" ) # Minimal sanity checks testthat::expect_type(res, "list") expect_numeric_scalar(res$baseline_ic) expect_numeric_scalar(res$optimal_ic) expect_phylo_or_null(res$tree_no_uncertainty_untransformed) }) # Test: searchOptimalConfiguration returns sensible output when no shifts are accepted (shift_acceptance_threshold=1e9 to forbid shifts) test_that("searchOptimalConfiguration returns sensible output when no shifts are accepted", { skip_if_missing_deps() simdata <- load_simdata_fixture() built <- build_baseline_and_data(simdata) baseline <- built$tree X <- built$X set.seed(789) res <- searchOptimalConfiguration( baseline_tree = baseline, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 10, num_cores = 1, shift_acceptance_threshold = 1e9, # effectively forbids accepting any shift plot = FALSE, IC = "GIC", store_model_fit_history = TRUE, method = "LL", uncertaintyweights = TRUE ) # No shifts detected testthat::expect_equal(length(res$shift_nodes_no_uncertainty), 0L) # Optimal IC equals baseline IC testthat::expect_equal(res$optimal_ic, res$baseline_ic, tolerance = 1e-8) # Trees for "no-uncertainty" path may be NULL (since no shift model exists); allow NULL or phylo expect_phylo_or_null(res$tree_no_uncertainty_untransformed) expect_phylo_or_null(res$tree_no_uncertainty_transformed) # Model may be NULL in this path testthat::expect_true(is.null(res$model_no_uncertainty) || is.list(res$model_no_uncertainty)) # History exists and contains only rejected entries — be robust to type (logical/num/char/factor) if (!is.null(res$model_fit_history) && is.list(res$model_fit_history)) { hist <- res$model_fit_history if (!is.null(hist$ic_acceptance_matrix)) { acc <- hist$ic_acceptance_matrix testthat::expect_true(is.matrix(acc) || is.data.frame(acc)) if (NCOL(acc) >= 2) { vals <- acc[, 2] # flatten to a simple vector vals <- if (is.data.frame(vals)) unlist(vals, use.names = FALSE) else vals vals <- if (is.list(vals)) unlist(vals, use.names = FALSE) else vals if (is.factor(vals)) vals <- as.character(vals) # Coerce robustly and ensure no TRUE is_true <- rep(FALSE, length(vals)) if (is.logical(vals)) { is_true <- vals } else if (is.numeric(vals)) { is_true <- vals != 0 } else if (is.character(vals)) { v <- trimws(tolower(vals)) is_true <- v %in% c("true", "t", "1") } testthat::expect_false(any(is_true, na.rm = TRUE)) } } } }) # Test: searchOptimalConfiguration records accepted steps with history (and covers plot/postorder) (threshold=-Inf; plot=TRUE; store_model_fit_history=TRUE) test_that("searchOptimalConfiguration records accepted steps with history (and covers plot/postorder)", { skip_if_missing_deps() simdata <- load_simdata_fixture() built <- build_baseline_and_data(simdata) baseline <- built$tree X <- built$X # Send plotting to a null device to cover the plotting branch safely in CI grDevices::pdf(NULL) on.exit({ try(grDevices::dev.off(), silent = TRUE) }, add = TRUE) set.seed(10101) res <- searchOptimalConfiguration( baseline_tree = baseline, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 10, # broader candidate set num_cores = 1, shift_acceptance_threshold = -Inf, # force acceptance of the first candidate evaluated plot = TRUE, # hit plotSimmap/nodelabels branches #postorder_traversal = TRUE, # hit postorder switch IC = "GIC", store_model_fit_history = TRUE, # ensure history writer runs method = "LL" ) # We expect at least one shift to be recorded/accepted under -Inf threshold testthat::expect_type(res, "list") testthat::expect_true(length(res$shift_nodes_no_uncertainty) >= 1L) # History object present and shows at least one accepted evaluation testthat::expect_true(!is.null(res$model_fit_history)) hist <- res$model_fit_history # Flexible handling: some implementations capture acceptance in either a list of fits or a matrix any_accept <- FALSE if (is.list(hist$fits)) { flags <- vapply(hist$fits, function(e) isTRUE(e$accepted), logical(1)) any_accept <- any(flags, na.rm = TRUE) } if (!any_accept && !is.null(hist$ic_acceptance_matrix)) { acc <- hist$ic_acceptance_matrix if (NCOL(acc) >= 2) { vals <- acc[, 2] vals <- if (is.data.frame(vals)) unlist(vals, use.names = FALSE) else vals vals <- if (is.list(vals)) unlist(vals, use.names = FALSE) else vals if (is.factor(vals)) vals <- as.character(vals) if (is.logical(vals)) { any_accept <- any(vals, na.rm = TRUE) } else if (is.numeric(vals)) { any_accept <- any(vals != 0, na.rm = TRUE) } else if (is.character(vals)) { v <- trimws(tolower(vals)) any_accept <- any(v %in% c("true", "t", "1")) } } } testthat::expect_true(any_accept) # Final assembly fields present (ensures we traversed to the end) testthat::expect_true(all(c("user_input", "optimal_ic", "baseline_ic", "IC_used", "num_candidates") %in% names(res))) }) # Test: searchOptimalConfiguration emits progress output when verbose = TRUE (captures stdout+messages and matches progress text) test_that("searchOptimalConfiguration emits progress output when verbose = TRUE", { skip_if_missing_deps() # Prevent helper-level verbosity (getOption("bifrost.verbose")) from interfering old_opt <- getOption("bifrost.verbose") options(bifrost.verbose = FALSE) on.exit(options(bifrost.verbose = old_opt), add = TRUE) set.seed(999) tr <- ape::rtree(25) X <- matrix(rnorm(25 * 2), ncol = 2) rownames(X) <- tr$tip.label # Helper: capture BOTH message() and stdout, then search across both capture_both <- function(expr) { out <- character() msgs <- testthat::capture_messages({ out <<- testthat::capture_output(expr) }) paste(c(out, msgs), collapse = "\n") } # Case A: plot = FALSE combined_a <- capture_both( searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 20, num_cores = 1, shift_acceptance_threshold = 1e9, plot = FALSE, store_model_fit_history = FALSE, method = "LL", verbose = TRUE ) ) testthat::expect_true(grepl("Generating candidate shift models", combined_a)) # Case B: plot = TRUE (use null device so plotting doesn’t pop windows) grDevices::pdf(NULL) on.exit(try(grDevices::dev.off(), silent = TRUE), add = TRUE) combined_b <- capture_both( searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 20, num_cores = 1, shift_acceptance_threshold = 1e9, plot = TRUE, store_model_fit_history = FALSE, method = "LL", verbose = TRUE ) ) testthat::expect_true(grepl("Generating candidate shift models", combined_b)) }) # Test: searchOptimalConfiguration is quiet when verbose = FALSE (captures stdout+messages; expects empty output) test_that("searchOptimalConfiguration is quiet when verbose = FALSE", { skip_if_missing_deps() # Prevent helper-level verbosity (getOption("bifrost.verbose")) from interfering old_opt <- getOption("bifrost.verbose") options(bifrost.verbose = FALSE) on.exit(options(bifrost.verbose = old_opt), add = TRUE) set.seed(1000) tr <- ape::rtree(25) X <- matrix(rnorm(25 * 2), ncol = 2) rownames(X) <- tr$tip.label capture_both <- function(expr) { out <- character() msgs <- testthat::capture_messages({ out <<- testthat::capture_output(expr) }) list(out = paste(out, collapse = "\n"), msgs = paste(msgs, collapse = "\n")) } # plot = FALSE cap_a <- capture_both( searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 20, num_cores = 1, shift_acceptance_threshold = 1e9, plot = FALSE, store_model_fit_history = FALSE, method = "LL", verbose = FALSE ) ) testthat::expect_equal(nchar(cap_a$msgs), 0) testthat::expect_equal(nchar(cap_a$out), 0) # plot = TRUE grDevices::pdf(NULL) on.exit(try(grDevices::dev.off(), silent = TRUE), add = TRUE) cap_b <- capture_both( searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 20, num_cores = 1, shift_acceptance_threshold = 1e9, plot = TRUE, store_model_fit_history = FALSE, method = "LL", verbose = FALSE ) ) testthat::expect_equal(nchar(cap_b$msgs), 0) testthat::expect_equal(nchar(cap_b$out), 0) }) # Group: validation and error handling # Test: searchOptimalConfiguration errors on invalid IC (IC='AIC' should error) test_that("searchOptimalConfiguration errors on invalid IC", { skip_if_missing_deps() set.seed(1) tr <- ape::rtree(25) X <- matrix(rnorm(25 * 2), ncol = 2) rownames(X) <- tr$tip.label testthat::expect_error( searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 10, num_cores = 1, shift_acceptance_threshold = 1e9, plot = FALSE, store_model_fit_history = FALSE, method = "LL", verbose = FALSE, IC = "AIC" ), "IC must be GIC or BIC" ) }) # Test: searchOptimalConfiguration errors if both uncertaintyweights flags are TRUE (sets uncertaintyweights and uncertaintyweights_par TRUE) test_that("searchOptimalConfiguration errors if both uncertaintyweights flags are TRUE", { skip_if_missing_deps() set.seed(2) tr <- ape::rtree(25) X <- matrix(rnorm(25 * 2), ncol = 2) rownames(X) <- tr$tip.label testthat::expect_error( searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 20, num_cores = 1, shift_acceptance_threshold = 1e9, plot = FALSE, store_model_fit_history = FALSE, method = "LL", verbose = FALSE, IC = "GIC", uncertaintyweights = TRUE, uncertaintyweights_par = TRUE ), "Exactly one of uncertaintyweights or uncertaintyweights_par must be TRUE" ) }) # Group: branch coverage and environment handling # Test: searchOptimalConfiguration skips IC weights (parallel) when no shifts are detected (uncertaintyweights_par=TRUE with no shifts; empty ic_weights) test_that("searchOptimalConfiguration skips IC weights (parallel) when no shifts are detected", { skip_if_missing_deps() set.seed(3) tr <- ape::rtree(25) X <- matrix(rnorm(25 * 2), ncol = 2) rownames(X) <- tr$tip.label res <- searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 10, num_cores = 1, shift_acceptance_threshold = 1e9, plot = FALSE, store_model_fit_history = FALSE, method = "LL", verbose = FALSE, IC = "GIC", uncertaintyweights_par = TRUE ) testthat::expect_true("ic_weights" %in% names(res)) testthat::expect_true(is.data.frame(res$ic_weights)) testthat::expect_equal(nrow(res$ic_weights), 0L) # Optional: check schema stays stable testthat::expect_true(all(c( "node","ic_with_shift","ic_without_shift","delta_ic", "ic_weight_withshift","ic_weight_withoutshift","evidence_ratio" ) %in% names(res$ic_weights))) }) # Test: searchOptimalConfiguration takes multisession path when RSTUDIO=1 (sets RSTUDIO env var before call) test_that("searchOptimalConfiguration takes multisession path when RSTUDIO=1", { skip_if_missing_deps() old_rstudio <- Sys.getenv("RSTUDIO", unset = NA_character_) Sys.setenv(RSTUDIO = "1") on.exit({ if (is.na(old_rstudio)) Sys.unsetenv("RSTUDIO") else Sys.setenv(RSTUDIO = old_rstudio) }, add = TRUE) set.seed(4) tr <- ape::rtree(25) X <- matrix(rnorm(25 * 2), ncol = 2) rownames(X) <- tr$tip.label res <- searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 20, num_cores = 1, shift_acceptance_threshold = 1e9, plot = FALSE, store_model_fit_history = FALSE, method = "LL", verbose = FALSE, IC = "GIC" ) testthat::expect_type(res, "list") }) # Test: searchOptimalConfiguration restores BLAS/OpenMP env vars after candidate scoring (pre-sets OMP_NUM_THREADS and checks restoration) test_that("searchOptimalConfiguration restores BLAS/OpenMP env vars after candidate scoring", { skip_if_missing_deps() thread_vars <- c( "OMP_NUM_THREADS","OPENBLAS_NUM_THREADS","MKL_NUM_THREADS", "VECLIB_MAXIMUM_THREADS","NUMEXPR_NUM_THREADS" ) old <- Sys.getenv(thread_vars, unset = NA_character_) # Pre-set at least one var so restore hits the else branch Sys.setenv(OMP_NUM_THREADS = "3") on.exit({ for (nm in names(old)) { val <- old[[nm]] if (is.na(val) || val == "") { Sys.unsetenv(nm) } else { do.call(Sys.setenv, setNames(list(val), nm)) } } }, add = TRUE) set.seed(5) tr <- ape::rtree(25) X <- matrix(rnorm(25 * 2), ncol = 2) rownames(X) <- tr$tip.label searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 20, num_cores = 1, shift_acceptance_threshold = 1e9, plot = FALSE, store_model_fit_history = FALSE, method = "LL", verbose = FALSE, IC = "GIC" ) testthat::expect_identical(Sys.getenv("OMP_NUM_THREADS"), "3") }) # Test: searchOptimalConfiguration returns consistent ic_weights for serial vs parallel modes (same seed; compares serial vs parallel weights) test_that("searchOptimalConfiguration returns consistent ic_weights for serial vs parallel modes", { skip_if_missing_deps() set.seed(1414) # Build a tree and pre-paint it (matches what searchOptimalConfiguration does internally) tr0 <- ape::rtree(40) tr <- phytools::paintSubTree(ape::as.phylo(tr0), node = ape::Ntip(tr0) + 1L, state = 0) # Build trait matrix aligned to the painted tree tip order X <- matrix(rnorm(40 * 2), ncol = 2) rownames(X) <- tr$tip.label # Run 1: serial IC weights set.seed(1414) res_ser <- suppressWarnings(searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 5, num_cores = 1, shift_acceptance_threshold = -Inf, plot = FALSE, store_model_fit_history = FALSE, method = "LL", verbose = FALSE, IC = "GIC", uncertaintyweights = TRUE )) # Run 2: parallel IC weights (deterministic when num_cores = 1) set.seed(1414) res_par <- suppressWarnings(searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 5, num_cores = 1, shift_acceptance_threshold = -Inf, plot = FALSE, store_model_fit_history = FALSE, method = "LL", verbose = FALSE, IC = "GIC", uncertaintyweights_par = TRUE )) testthat::expect_true("ic_weights" %in% names(res_ser)) testthat::expect_true("ic_weights" %in% names(res_par)) testthat::expect_true(is.data.frame(res_ser$ic_weights)) testthat::expect_true(is.data.frame(res_par$ic_weights)) # If either is empty, both should be empty under fixed seed + num_cores=1 if (nrow(res_ser$ic_weights) == 0L || nrow(res_par$ic_weights) == 0L) { testthat::expect_equal(nrow(res_ser$ic_weights), 0L) testthat::expect_equal(nrow(res_par$ic_weights), 0L) return(invisible(NULL)) } cols <- c( "node", "ic_with_shift", "ic_without_shift", "delta_ic", "ic_weight_withshift", "ic_weight_withoutshift", "evidence_ratio" ) testthat::expect_true(all(cols %in% names(res_ser$ic_weights))) testthat::expect_true(all(cols %in% names(res_par$ic_weights))) ser <- res_ser$ic_weights[, cols, drop = FALSE] par <- res_par$ic_weights[, cols, drop = FALSE] # order-independent compare ser <- ser[order(ser$node), , drop = FALSE]; rownames(ser) <- NULL par <- par[order(par$node), , drop = FALSE]; rownames(par) <- NULL testthat::expect_equal(ser$node, par$node) num_cols <- setdiff(cols, "node") for (nm in num_cols) { testthat::expect_equal(ser[[nm]], par[[nm]], tolerance = 1e-10) } }) # Group: side effects and state restoration # Test: searchOptimalConfiguration does not write files to the working directory (runs in temp wd; compares file list) test_that("searchOptimalConfiguration does not write files to the working directory", { skip_if_missing_deps() # Isolated working directory (base R only) wd <- tempfile("bifrost-wd-") dir.create(wd, recursive = TRUE) oldwd <- getwd() on.exit(setwd(oldwd), add = TRUE) setwd(wd) before <- list.files(".", recursive = TRUE, all.files = TRUE) set.seed(123) tr <- ape::rtree(30) X <- matrix(rnorm(30 * 2), ncol = 2) rownames(X) <- tr$tip.label res <- searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 10, num_cores = 1, shift_acceptance_threshold = 1e9, plot = FALSE, IC = "GIC", store_model_fit_history = TRUE, # key path we care about method = "LL", verbose = FALSE ) after <- list.files(".", recursive = TRUE, all.files = TRUE) # Nothing new should appear in working directory testthat::expect_identical(after, before) # Optional sanity: if history is requested, it should be under tempdir(), not getwd() testthat::expect_true( !dir.exists(file.path(getwd(), "bifrost_fit_history")) ) }) # Test: searchOptimalConfiguration restores options(bifrost.verbose) (sets option FALSE; verbose=TRUE toggles internally) test_that("searchOptimalConfiguration restores options(bifrost.verbose)", { skip_if_missing_deps() old_opt <- getOption("bifrost.verbose") options(bifrost.verbose = FALSE) on.exit(options(bifrost.verbose = old_opt), add = TRUE) set.seed(123) tr <- ape::rtree(25) X <- matrix(rnorm(25 * 2), ncol = 2) rownames(X) <- tr$tip.label res <- suppressMessages(searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 10, num_cores = 1, shift_acceptance_threshold = 1e9, plot = FALSE, IC = "GIC", store_model_fit_history = FALSE, method = "LL", verbose = TRUE # triggers internal options() change )) testthat::expect_identical(getOption("bifrost.verbose"), FALSE) }) # Test: ic_weights has stable schema when requested (uncertaintyweights_par=TRUE; validates columns) test_that("ic_weights has stable schema when requested", { skip_if_missing_deps() set.seed(3) tr <- ape::rtree(25) X <- matrix(rnorm(25 * 2), ncol = 2) rownames(X) <- tr$tip.label res <- searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 10, num_cores = 1, shift_acceptance_threshold = 1e9, # likely no shifts plot = FALSE, store_model_fit_history = FALSE, method = "LL", verbose = FALSE, IC = "GIC", uncertaintyweights_par = TRUE ) testthat::expect_true("ic_weights" %in% names(res)) testthat::expect_true(is.data.frame(res$ic_weights)) testthat::expect_true(all(c( "node","ic_with_shift","ic_without_shift","delta_ic", "ic_weight_withshift","ic_weight_withoutshift","evidence_ratio" ) %in% names(res$ic_weights))) }) # Test: model_fit_history ic_acceptance_matrix is well-formed (store_model_fit_history=TRUE; checks 2-column matrix) test_that("model_fit_history ic_acceptance_matrix is well-formed", { skip_if_missing_deps() simdata <- load_simdata_fixture() built <- build_baseline_and_data(simdata) set.seed(123) res <- searchOptimalConfiguration( baseline_tree = built$tree, trait_data = built$X, formula = "trait_data ~ 1", min_descendant_tips = 10, num_cores = 1, shift_acceptance_threshold = 1e9, plot = FALSE, IC = "GIC", store_model_fit_history = TRUE, method = "LL", verbose = FALSE ) testthat::expect_true(is.list(res$model_fit_history)) testthat::expect_true("ic_acceptance_matrix" %in% names(res$model_fit_history)) mat <- res$model_fit_history$ic_acceptance_matrix testthat::expect_true(is.matrix(mat)) testthat::expect_equal(ncol(mat), 2L) # acceptance column should be coercible to logical without producing all NA acc <- mat[, 2] acc_lgl <- suppressWarnings(as.logical(acc)) testthat::expect_false(all(is.na(acc_lgl))) }) # Test: no-shifts path yields a usable model_no_uncertainty (shift_acceptance_threshold=1e9 ensures no shifts) test_that("no-shifts path yields a usable model_no_uncertainty", { skip_if_missing_deps() set.seed(999) tr <- ape::rtree(25) X <- matrix(rnorm(25 * 2), ncol = 2) rownames(X) <- tr$tip.label res <- searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 20, num_cores = 1, shift_acceptance_threshold = 1e9, # no shifts plot = FALSE, IC = "GIC", store_model_fit_history = FALSE, method = "LL", verbose = FALSE ) testthat::expect_equal(length(res$shift_nodes_no_uncertainty), 0L) # should not be NULL in a "nice" API; if it is today, this test will flag it. testthat::expect_true(!is.null(res$model_no_uncertainty)) }) # Test: searchOptimalConfiguration captures warnings from shift evaluation (mocks fitMvglsAndExtractGIC.formula to warn) test_that("searchOptimalConfiguration captures warnings from shift evaluation", { skip_if_missing_deps() ns <- asNamespace("bifrost") orig_fit <- get("fitMvglsAndExtractGIC.formula", envir = ns) testthat::local_mocked_bindings( fitMvglsAndExtractGIC.formula = function(formula, tree, trait_data, ...) { in_withCallingHandlers <- any(vapply(sys.calls(), function(cl) { is.call(cl) && is.name(cl[[1]]) && identical(as.character(cl[[1]]), "withCallingHandlers") }, logical(1))) if (in_withCallingHandlers) warning("forced warning from test") orig_fit(formula, tree, trait_data, ...) }, .env = ns ) set.seed(6) tr <- ape::rtree(25) X <- matrix(rnorm(25 * 2), ncol = 2) rownames(X) <- tr$tip.label res <- suppressWarnings(searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 5, num_cores = 1, shift_acceptance_threshold = 1e9, plot = FALSE, store_model_fit_history = FALSE, method = "LL", verbose = FALSE, IC = "GIC" )) testthat::expect_true(!is.null(res$warnings)) testthat::expect_true(any(grepl("Warning in evaluating shift at node", unlist(res$warnings)))) }) # Test: searchOptimalConfiguration records error entries in history and yields NA_real_ row (mocks addShiftToModel to return NULL tree) test_that("searchOptimalConfiguration records error entries in history and yields NA_real_ row", { skip_if_missing_deps() ns <- asNamespace("bifrost") testthat::local_mocked_bindings( addShiftToModel = function(tree, shift_node, shift_id) { list(tree = NULL, shift_id = shift_id + 1L) # shifted_tree becomes NULL => fit errors }, .env = ns ) set.seed(7) tr <- ape::rtree(25) X <- matrix(rnorm(25 * 2), ncol = 2) rownames(X) <- tr$tip.label res <- suppressWarnings(searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 5, num_cores = 1, shift_acceptance_threshold = 1e9, plot = FALSE, store_model_fit_history = TRUE, method = "LL", verbose = FALSE, IC = "GIC" )) testthat::expect_true(!is.null(res$model_fit_history$ic_acceptance_matrix)) mat <- res$model_fit_history$ic_acceptance_matrix testthat::expect_true(any(is.na(mat[, 1]), na.rm = TRUE)) testthat::expect_true(!is.null(res$warnings)) testthat::expect_true(any(grepl("Error in evaluating shift at node", unlist(res$warnings)))) }) # Test: searchOptimalConfiguration uses cat() progress path in interactive RStudio plotting (interactive+RSTUDIO=1; plot=TRUE with min_descendant_tips=Ntip) test_that("searchOptimalConfiguration uses cat() progress path in interactive RStudio plotting", { skip_if_missing_deps() testthat::skip_if_not(interactive()) old_rstudio <- Sys.getenv("RSTUDIO", unset = NA_character_) Sys.setenv(RSTUDIO = "1") on.exit({ if (is.na(old_rstudio)) Sys.unsetenv("RSTUDIO") else Sys.setenv(RSTUDIO = old_rstudio) }, add = TRUE) # Null device so plot=TRUE doesn't pop windows grDevices::pdf(NULL) on.exit(try(grDevices::dev.off(), silent = TRUE), add = TRUE) set.seed(123) tr <- ape::rtree(20) X <- matrix(rnorm(20 * 2), ncol = 2) rownames(X) <- tr$tip.label # Critical trick: # Set min_descendant_tips to Ntip so ONLY the root is eligible. # That makes candidate_trees_shifts empty => the main loop never runs, # so the plot code never calls getStates(shifted_tree,...). out <- testthat::capture_output({ suppressWarnings(suppressMessages(searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = ape::Ntip(tr), num_cores = 1, shift_acceptance_threshold = 1e9, plot = TRUE, IC = "GIC", store_model_fit_history = FALSE, method = "LL", verbose = TRUE ))) }) txt <- paste(out, collapse = "\n") testthat::expect_true(grepl("Generating candidate shift models", txt)) }) # Test: searchOptimalConfiguration serial ic_weights executes BIC branch (mocks fitMvglsAndExtractBIC.formula; uncertaintyweights=TRUE) test_that("searchOptimalConfiguration serial ic_weights executes BIC branch", { skip_if_missing_deps() ns <- asNamespace("bifrost") # Deterministic decreasing BIC so shifts are accepted and weights run k <- 0L testthat::local_mocked_bindings( fitMvglsAndExtractBIC.formula = function(formula, tree, trait_data, ...) { k <<- k + 1L bic_val <- 1000 - 10 * k list( model = list(corrSt = list(phy = tree)), BIC = list(BIC = bic_val) ) }, # Make shift-removal safe & deterministic for weights loop removeShiftFromTree = function(tree, shift_node, stem = FALSE) tree, .env = ns ) set.seed(999) tr <- ape::rtree(40) X <- matrix(rnorm(40 * 2), ncol = 2) rownames(X) <- tr$tip.label res <- suppressWarnings(searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 2, num_cores = 1, shift_acceptance_threshold = -Inf, # accept shifts plot = FALSE, store_model_fit_history = FALSE, verbose = FALSE, IC = "BIC", uncertaintyweights = TRUE # SERIAL weights path )) testthat::expect_true("ic_weights" %in% names(res)) testthat::expect_true(is.data.frame(res$ic_weights)) testthat::expect_true(nrow(res$ic_weights) >= 1L) }) # Test: searchOptimalConfiguration captures warnings from shift evaluation (BIC) (mocks fitMvglsAndExtractBIC.formula to warn) test_that("searchOptimalConfiguration captures warnings from shift evaluation (BIC)", { skip_if_missing_deps() ns <- asNamespace("bifrost") orig_fit <- get("fitMvglsAndExtractBIC.formula", envir = ns) testthat::local_mocked_bindings( fitMvglsAndExtractBIC.formula = function(formula, tree, trait_data, ...) { in_withCallingHandlers <- any(vapply(sys.calls(), function(cl) { is.call(cl) && is.name(cl[[1]]) && identical(as.character(cl[[1]]), "withCallingHandlers") }, logical(1))) if (in_withCallingHandlers) warning("forced warning from test (BIC)") orig_fit(formula, tree, trait_data, ...) }, .env = ns ) set.seed(456) tr <- ape::rtree(25) X <- matrix(rnorm(25 * 2), ncol = 2) rownames(X) <- tr$tip.label res <- suppressWarnings(searchOptimalConfiguration( baseline_tree = tr, trait_data = X, formula = "trait_data ~ 1", min_descendant_tips = 5, num_cores = 1, shift_acceptance_threshold = 1e9, # reject; still evaluates candidates and triggers handler plot = FALSE, store_model_fit_history = FALSE, method = "LL", verbose = FALSE, IC = "BIC" )) testthat::expect_true(!is.null(res$warnings)) testthat::expect_true(any(grepl("Warning in evaluating shift at node", unlist(res$warnings)))) })