# Tests for comparability() -- split-half factor comparability (M46). # Shared fixture: one seeded PCA run on sim16, reused across structural, # discriminating, and print/plot tests (memoised to keep the suite fast). .cmp_cache <- new.env(parent = emptyenv()) .get_cmp <- function() { if (is.null(.cmp_cache$cmp)) { .cmp_cache$cmp <- suppressMessages( comparability(sim16, k_max = 5, n_splits = 4, seed = 1) ) } .cmp_cache$cmp } # ---- Object structure -------------------------------------------------------- test_that("comparability() returns a well-formed object", { cmp <- .get_cmp() expect_s3_class(cmp, "comparability") expect_named( cmp, c( "coefficients", "summary", "k_max", "k_requested", "n_splits", "n_half", "engine", "cor", "fm", "n_obs", "n_vars", "seed", "call" ) ) expect_equal(cmp$k_max, 5L) expect_equal(cmp$k_requested, 5L) expect_equal(cmp$n_splits, 4L) expect_equal(cmp$n_half, 500L) expect_equal(cmp$engine, "pca") expect_equal(cmp$n_obs, 1000L) expect_equal(cmp$n_vars, 16L) # One row per split x level x factor; one summary row per level x factor. n_factors <- sum(seq_len(5L)) expect_equal(nrow(cmp$coefficients), 4L * n_factors) expect_equal(nrow(cmp$summary), n_factors) expect_named(cmp$coefficients, c("split", "level", "factor", "r", "phi")) expect_named( cmp$summary, c("level", "factor", "r_median", "r_min", "phi_median", "phi_min", "n_splits_ok") ) # Coefficients are correlations / congruences: bounded by 1 in magnitude. expect_true(all(abs(cmp$coefficients$r) <= 1 + 1e-8)) expect_true(all(abs(cmp$coefficients$phi) <= 1 + 1e-8)) expect_true(all(cmp$summary$n_splits_ok == 4L)) # Factor labels match the full-sample ackwards() labelling scheme. expect_true(all(grepl("^m\\d+f\\d+$", cmp$summary$factor))) }) # ---- Discriminating behaviour (the acceptance-criterion test) ---------------- test_that("comparability() separates replicable from non-replicable structure", { cmp <- .get_cmp() sm <- cmp$summary # sim16 has a known 1 -> 2 -> 4 hierarchy: those levels replicate cleanly. expect_true(all(sm$r_median[sm$level %in% c(1L, 2L, 4L)] > 0.95)) expect_true(all(sm$phi_median[sm$level %in% c(1L, 2L, 4L)] > 0.95)) # Level 3 sits between the true 2- and 4-factor structures (an arbitrary # blend), so at least one of its factors fails to replicate. expect_lt(min(sm$r_median[sm$level == 3L]), 0.9) # Level 5 is overextracted by design: the first four factors are the true # 4-structure (replicate), the fifth is a sample-idiosyncratic fragment. lev5 <- sm[sm$level == 5L, , drop = FALSE] expect_true(all(lev5$r_median[lev5$factor != "m5f5"] > 0.95)) expect_lt(lev5$r_median[lev5$factor == "m5f5"], 0.5) expect_lt(lev5$phi_median[lev5$factor == "m5f5"], 0.5) }) test_that("comparability() flags a pure-noise factor as non-replicable", { set.seed(99) dat <- cbind( sim16[, 1:8], noise1 = rnorm(1000), noise2 = rnorm(1000), noise3 = rnorm(1000) ) cmp <- suppressMessages(comparability(dat, k_max = 3, n_splits = 4, seed = 1)) sm <- cmp$summary # The two real factors at level 3 replicate; the noise-anchored one does not. lev3 <- sm[sm$level == 3L, , drop = FALSE] expect_lt(min(lev3$r_median), 0.8) expect_equal(sum(lev3$r_median > 0.95), 2L) }) # ---- Internal algebra: identity, invariance, oracle --------------------------- test_that(".cross_cor() of a solution with itself has a unit diagonal", { R <- stats::cor(sim16) lev <- pca_levels(R, k_max = 3L)$levels[["3"]] E <- .cross_cor(lev, lev, R) expect_equal(diag(E), rep(1, 3L), tolerance = 1e-12, ignore_attr = TRUE) # Self-comparability: every factor matches itself with r = phi = 1. out <- .level_comparability(lev, lev, lev, R) expect_equal(out$r, rep(1, 3L), tolerance = 1e-12) expect_equal(out$phi, rep(1, 3L), tolerance = 1e-12) }) test_that(".cross_cor() agrees with materialised half-solution scores", { # Invariant 2 extended to cross-solution correlations: the W'RW algebra on # the pooled R must match correlating actual scores computed by applying # both halves' weights to the full (complete-data) sample. dat <- as.matrix(sim16) R <- stats::cor(dat) half_a <- dat[1:500, , drop = FALSE] half_b <- dat[501:1000, , drop = FALSE] lev_a <- pca_levels(stats::cor(half_a), k_max = 3L)$levels[["3"]] lev_b <- pca_levels(stats::cor(half_b), k_max = 3L)$levels[["3"]] E_algebra <- .cross_cor(lev_a, lev_b, R) Z <- scale(dat) E_scores <- stats::cor(Z %*% lev_a$scoring$weights, Z %*% lev_b$scoring$weights) expect_equal(unname(E_algebra), unname(E_scores), tolerance = 1e-12) }) test_that(".level_comparability() is invariant to column order and sign", { dat <- as.matrix(sim16) R <- stats::cor(dat) lev_f <- pca_levels(R, k_max = 3L)$levels[["3"]] lev_a <- pca_levels(stats::cor(dat[1:500, ]), k_max = 3L)$levels[["3"]] lev_b <- pca_levels(stats::cor(dat[501:1000, ]), k_max = 3L)$levels[["3"]] base <- .level_comparability(lev_f, lev_a, lev_b, R) # Permute and sign-flip half B's factors; the anchored coefficients must # not change (matching + sign alignment absorb both transformations). perm <- c(3L, 1L, 2L) flips <- c(-1, 1, -1) lev_b2 <- lev_b lev_b2$loadings <- sweep(lev_b$loadings[, perm, drop = FALSE], 2L, flips, "*") lev_b2$scoring$weights <- sweep(lev_b$scoring$weights[, perm, drop = FALSE], 2L, flips, "*") permuted <- .level_comparability(lev_f, lev_a, lev_b2, R) expect_equal(permuted$r, base$r, tolerance = 1e-12) expect_equal(permuted$phi, base$phi, tolerance = 1e-12) }) test_that(".match_square() picks the global-max bijection", { E <- rbind( c(0.2, 0.9, 0.1), c(0.85, 0.3, 0.1), c(0.4, 0.5, -0.95) # negative: matching is on |r| ) expect_identical(.match_square(E), c(2L, 1L, 3L)) expect_identical(.match_square(diag(3L)), 1:3) }) test_that(".level_comparability() returns NA rows for a missing half-level", { R <- stats::cor(sim16) lev <- pca_levels(R, k_max = 2L)$levels[["2"]] out <- .level_comparability(lev, NULL, lev, R) expect_equal(out$factor, lev$labels) expect_true(all(is.na(out$r))) expect_true(all(is.na(out$phi))) }) # ---- Reproducibility ---------------------------------------------------------- test_that("comparability() is reproducible under a seed", { a <- suppressMessages(comparability(sim16, k_max = 3, n_splits = 2, seed = 42)) b <- suppressMessages(comparability(sim16, k_max = 3, n_splits = 2, seed = 42)) expect_identical(a$coefficients, b$coefficients) expect_identical(a$summary, b$summary) c <- suppressMessages(comparability(sim16, k_max = 3, n_splits = 2, seed = 43)) expect_false(identical(a$coefficients$r, c$coefficients$r)) }) # ---- EFA engine --------------------------------------------------------------- test_that("comparability() works with the EFA engine", { cmp <- suppressMessages( comparability(sim16[, 1:8], k_max = 3, engine = "efa", n_splits = 2, seed = 1) ) expect_s3_class(cmp, "comparability") expect_equal(cmp$engine, "efa") expect_equal(cmp$fm, "minres") # The true 2-factor structure of these 8 items replicates. expect_true(all(cmp$summary$r_median[cmp$summary$level == 2L] > 0.95)) }) # ---- Validation ---------------------------------------------------------------- test_that("comparability() rejects unsupported engines and bases with pointers", { expect_error( comparability(sim16, k_max = 3, engine = "esem"), "not supported" ) expect_error( comparability(sim16, k_max = 3, cor = "polychoric"), "not supported" ) expect_error(comparability(sim16, k_max = 3, engine = "midi"), "must be one of") expect_error(comparability(sim16, k_max = 3, cor = "kendall"), "must be one of") }) test_that("comparability() rejects non-raw-data input", { R <- stats::cor(sim16) expect_error(comparability(R, k_max = 3), "raw item data") expect_error(comparability(list(a = 1), k_max = 3), "data frame or numeric matrix") ch <- matrix(letters[1:20], nrow = 5) expect_error(comparability(ch, k_max = 3), "numeric columns") }) test_that("comparability() validates n_splits and half-sample size", { expect_error(comparability(sim16, k_max = 3, n_splits = 0), "positive integer") expect_error(comparability(sim16, k_max = 3, n_splits = 1.5), "positive integer") expect_error(comparability(sim16, k_max = 3, n_splits = c(2, 3)), "positive integer") # n/2 must exceed p, or half-sample solutions cannot be scored. expect_error( comparability(sim16[1:30, ], k_max = 3), "more rows than variables" ) }) # ---- Convergence shortfalls (mocked) ------------------------------------------- test_that("a level missing from some half-fits yields NA + a shortfall message", { real_fit_half <- .fit_half calls <- 0L testthat::local_mocked_bindings( .fit_half = function(data_half, k_max, engine, cor, fm) { calls <<- calls + 1L out <- real_fit_half(data_half, k_max, engine, cor, fm) if (calls == 1L) out[["3"]] <- NULL # split 1, half A: level 3 "fails" out }, .package = "ackwards" ) expect_message( cmp <- comparability(sim16, k_max = 3, n_splits = 2, seed = 1), "did not converge" ) lev3 <- cmp$summary[cmp$summary$level == 3L, , drop = FALSE] expect_true(all(lev3$n_splits_ok == 1L)) expect_false(anyNA(lev3$r_median)) # one usable split still aggregates expect_equal(sum(is.na(cmp$coefficients$r)), 3L) # split 1's level-3 rows expect_no_error(print(cmp)) # "[1/2 splits usable]" suffix branch }) test_that("a level with zero usable splits aggregates to NA and prints", { real_fit_half <- .fit_half testthat::local_mocked_bindings( .fit_half = function(data_half, k_max, engine, cor, fm) { out <- real_fit_half(data_half, k_max, engine, cor, fm) out[["3"]] <- NULL # level 3 "fails" in every half out }, .package = "ackwards" ) expect_message( cmp <- comparability(sim16, k_max = 3, n_splits = 2, seed = 1), "did not converge" ) lev3 <- cmp$summary[cmp$summary$level == 3L, , drop = FALSE] expect_true(all(is.na(lev3$r_median))) expect_true(all(lev3$n_splits_ok == 0L)) expect_no_error(print(cmp)) # "no usable splits" branch }) # ---- print + autoplot ----------------------------------------------------------- test_that("print.comparability() runs and returns invisibly", { cmp <- .get_cmp() # cli output is not reliably captured by expect_output (see test-print.R); # assert the method runs and honours the print contract. expect_no_error(print(cmp)) expect_invisible(print(cmp)) }) test_that("autoplot.comparability() builds a ggplot", { skip_if_not_installed("ggplot2") cmp <- .get_cmp() p <- autoplot(cmp) expect_s3_class(p, "ggplot") # Both panels present: score comparability and loading congruence. built <- ggplot2::ggplot_build(p) expect_equal(length(levels(built$plot$data$panel)), 2L) }) # ---- Post-review follow-up coverage --------------------------------------------- test_that(".level_comparability() returns NA rows when cross matrices carry NA", { R <- stats::cor(sim16) lev <- pca_levels(R, k_max = 2L)$levels[["2"]] # An NA cell in the pooled R (pathological pairwise missingness) propagates # into the cross-solution matrices; matching must degrade to NA, not crash. R_bad <- R R_bad[1L, 2L] <- R_bad[2L, 1L] <- NA_real_ out <- .level_comparability(lev, lev, lev, R_bad) expect_equal(out$factor, lev$labels) expect_true(all(is.na(out$r))) expect_true(all(is.na(out$phi))) }) test_that(".fit_half() returns an empty levels list when a half cannot be factored", { d <- as.matrix(sim16[1:100, 1:6]) d[, 6] <- 1 # zero-variance column -> NA row/col in the half R expect_identical(.fit_half(d, k_max = 3L, engine = "pca", cor = "pearson", fm = "minres"), list()) expect_identical(.fit_half(d, k_max = 3L, engine = "efa", cor = "pearson", fm = "minres"), list()) }) test_that("unknown arguments in ... error loudly (typo guard)", { expect_error(comparability(sim16, k_max = 3, nsplits = 20), "Unknown argument") expect_error(suggest_k(sim16, kmax = 4), "Unknown argument") expect_error(ackwards(sim16, k_max = 3, alignsigns = FALSE), "Unknown argument") # Unnamed extras are rejected too. expect_error(comparability(sim16, 3, "pca", "pearson", "minres", 2, NULL, 42), "unnamed extra") # The M34 moved-args guard keeps its more specific message. expect_error(ackwards(sim16, k_max = 3, prune = "redundant"), "no longer") }) test_that("comparability() works with a single split and the spearman basis", { cmp1 <- suppressMessages(comparability(sim16, k_max = 2, n_splits = 1, seed = 5)) expect_equal(cmp1$n_splits, 1L) # With one split the median and min across splits coincide. expect_identical(cmp1$summary$r_median, cmp1$summary$r_min) cmp_sp <- suppressMessages( comparability(sim16, k_max = 2, cor = "spearman", n_splits = 2, seed = 5) ) expect_equal(cmp_sp$cor, "spearman") expect_true(all(cmp_sp$summary$r_median > 0.9)) # sim16's 2-level structure }) test_that("a truncated full-sample anchor is recorded and printed", { real_ackwards <- ackwards testthat::local_mocked_bindings( # Simulate the anchor truncating (non-convergence below the request): # whatever k_max comparability() asks for, the full-sample fit stops at 2. ackwards = function(data, k_max, ...) real_ackwards(data, k_max = 2L, ...), .package = "ackwards" ) cmp <- suppressMessages(comparability(sim16, k_max = 3, n_splits = 2, seed = 1)) expect_equal(cmp$k_max, 2L) expect_equal(cmp$k_requested, 3L) expect_true(all(cmp$coefficients$level <= 2L)) expect_no_error(print(cmp)) # "(requested 1-3; ...)" suffix branch }) test_that("autoplot.comparability() handles all-NA levels from shortfalls", { skip_if_not_installed("ggplot2") real_fit_half <- .fit_half testthat::local_mocked_bindings( .fit_half = function(data_half, k_max, engine, cor, fm) { out <- real_fit_half(data_half, k_max, engine, cor, fm) out[["3"]] <- NULL # level 3 "fails" in every half out }, .package = "ackwards" ) cmp <- suppressMessages(comparability(sim16, k_max = 3, n_splits = 2, seed = 1)) p <- autoplot(cmp) expect_s3_class(p, "ggplot") # NA coefficients are dropped from the plot data, not drawn. expect_false(anyNA(p$data$value)) expect_true(all(p$data$level <= 2L)) })