tests/testthat/test-vignette-m24.R

# Guard tests for vignette idioms introduced in M24:
# wide + delta comparison tables in engines/ordinal/forbes vignettes.

# Helper: primary loading per item at a given level
.primary_loading <- function(x, level_k, items) {
  d <- tidy(x, what = "loadings")
  d <- d[d$level == level_k & d$item %in% items, ]
  do.call(rbind, lapply(split(d, d$item), function(r) r[which.max(abs(r$loading)), ]))
}

# The bfi25 fits below are shared across several tests; polychoric correlation
# on 25 items is the dominant cost, and the same fit was previously recomputed
# up to 3x. Each distinct fit is computed once via the suite-wide cached()
# memo in helper-data.R (M48; it replaced this file's former `.vfit` local
# memo — nothing here mutates the returned objects, so sharing is safe).
.bfi_cc <- na.omit(bfi25)

test_that("engines vignette: loadings wide-pivot produces expected columns and shape", {
  # Guards loadings-compare chunk in ackwards-engines.Rmd
  skip_if_not_installed("psych")
  x_pca <- cached(ackwards(.bfi_cc, k_max = 3, cor = "polychoric"))
  x_efa <- cached(ackwards(.bfi_cc, k_max = 3, engine = "efa", cor = "polychoric"))

  anchors <- c("N1", "N2", "E1", "E2", "C1", "C2")
  pca_p <- .primary_loading(x_pca, 3, anchors)
  efa_p <- .primary_loading(x_efa, 3, anchors)

  # Alignment assertion must pass
  pca_s <- pca_p[order(pca_p$item), ]
  efa_s <- efa_p[order(efa_p$item), ]
  expect_equal(pca_s$factor, efa_s$factor)
  expect_equal(sign(pca_s$loading), sign(efa_s$loading))

  wide <- merge(
    pca_p[, c("factor", "item", "loading")],
    efa_p[, c("item", "loading")],
    by = "item", suffixes = c("_pca", "_efa")
  )
  wide$delta <- abs(wide$loading_efa) - abs(wide$loading_pca)

  expect_named(wide, c("item", "factor", "loading_pca", "loading_efa", "delta"),
    ignore.order = TRUE
  )
  expect_equal(nrow(wide), length(anchors))
  expect_true(all(wide$delta < 0),
    info = "EFA |loading| should be uniformly smaller than PCA |loading| for these anchor items"
  )
})

test_that("engines vignette: edges wide-pivot produces expected columns and no NA mismatch", {
  # Guards edges-compare chunk in ackwards-engines.Rmd
  skip_if_not_installed("psych")
  x_pca <- cached(ackwards(.bfi_cc, k_max = 3, cor = "polychoric"))
  x_efa <- cached(ackwards(.bfi_cc, k_max = 3, engine = "efa", cor = "polychoric"))

  pca_e <- tidy(x_pca, what = "edges", primary_only = TRUE)
  efa_e <- tidy(x_efa, what = "edges", primary_only = TRUE)

  wide_e <- merge(
    pca_e[, c("from", "to", "r")],
    efa_e[, c("from", "to", "r")],
    by = c("from", "to"), all = TRUE, suffixes = c("_pca", "_efa")
  )
  wide_e$delta <- abs(wide_e$r_efa) - abs(wide_e$r_pca)

  expect_named(wide_e, c("from", "to", "r_pca", "r_efa", "delta"), ignore.order = TRUE)
  expect_true(nrow(wide_e) > 0L)
  expect_true(all(is.finite(wide_e$delta)))
  # On well-structured data, primary parents should agree between engines
  expect_false(anyNA(wide_e),
    info = "Expect no engine disagreement on primary parents for BFI at k=3"
  )
})

test_that("ordinal vignette: loadings wide-pivot produces expected columns and positive deltas", {
  # Guards load-compare chunk in ackwards-ordinal.Rmd
  skip_if_not_installed("psych")
  x_pearson <- cached(suppressWarnings(ackwards(.bfi_cc, k_max = 5)))
  x_poly <- cached(ackwards(.bfi_cc, k_max = 5, cor = "polychoric"))

  n_items <- c("N1", "N2", "N3", "N4", "N5")
  pear_p <- .primary_loading(x_pearson, 5, n_items)
  poly_p <- .primary_loading(x_poly, 5, n_items)

  # Alignment assertion must pass
  pear_s <- pear_p[order(pear_p$item), ]
  poly_s <- poly_p[order(poly_p$item), ]
  expect_equal(pear_s$factor, poly_s$factor)
  expect_equal(sign(pear_s$loading), sign(poly_s$loading))

  wide <- merge(
    pear_p[, c("factor", "item", "loading")],
    poly_p[, c("item", "loading")],
    by = "item", suffixes = c("_pearson", "_poly")
  )
  wide$delta <- abs(wide$loading_poly) - abs(wide$loading_pearson)

  expect_named(wide, c("item", "factor", "loading_pearson", "loading_poly", "delta"),
    ignore.order = TRUE
  )
  expect_equal(nrow(wide), length(n_items))
  expect_true(all(wide$delta > 0),
    info = "Polychoric |loading| should be uniformly larger for Neuroticism items"
  )
})

test_that("ordinal vignette: edges wide-pivot produces expected columns and no NA mismatch", {
  # Guards edge-compare chunk in ackwards-ordinal.Rmd
  skip_if_not_installed("psych")
  x_pearson <- cached(suppressWarnings(ackwards(.bfi_cc, k_max = 5)))
  x_poly <- cached(ackwards(.bfi_cc, k_max = 5, cor = "polychoric"))

  pear_e <- tidy(x_pearson, what = "edges", primary_only = TRUE)
  poly_e <- tidy(x_poly, what = "edges", primary_only = TRUE)

  wide_e <- merge(
    pear_e[, c("from", "to", "r")],
    poly_e[, c("from", "to", "r")],
    by = c("from", "to"), all = TRUE, suffixes = c("_pearson", "_poly")
  )
  wide_e$delta <- abs(wide_e$r_poly) - abs(wide_e$r_pearson)

  expect_named(wide_e, c("from", "to", "r_pearson", "r_poly", "delta"), ignore.order = TRUE)
  expect_true(nrow(wide_e) > 0L)
  expect_true(all(is.finite(wide_e$delta)))
  expect_false(anyNA(wide_e),
    info = "Expect no basis disagreement on primary parents for BFI at k=5"
  )

  # Magnitude delta keeps the "positive = stronger connection" reading correct
  # even for the negatively-signed primary edge (m2f2 -> m3f2), which strengthens
  # under the polychoric basis. A signed delta would render that row negative.
  neg_edge <- wide_e[wide_e$r_pearson < 0 & wide_e$r_poly < 0, ]
  if (nrow(neg_edge) > 0L) {
    stronger <- abs(neg_edge$r_poly) > abs(neg_edge$r_pearson)
    expect_true(all(neg_edge$delta[stronger] > 0),
      info = "A strengthening negative edge must show a positive magnitude delta"
    )
  }
})

test_that("forbes vignette: prune-nodes table idiom returns expected columns and n_redundant", {
  # Guards prune-nodes chunk in ackwards-forbes.Rmd. prune() is cheap and
  # re-runnable without re-extraction (M34), so this reuses the shared
  # "poly5_all" base fit (see the pairs="all" test below) rather than
  # re-fitting: only prune() itself needs to run here.
  skip_if_not_installed("psych")
  x_base <- cached(ackwards(.bfi_cc, k_max = 5, cor = "polychoric", pairs = "all"))
  x_prune <- suppressMessages(prune(x_base, "redundant"))

  nodes <- tidy(x_prune, what = "nodes")
  n_redundant <- sum(nodes$pruned)

  expect_named(nodes, c("id", "level", "pruned", "prune_reason"), ignore.order = TRUE)
  expect_true(n_redundant > 0L, info = "Expect some redundant nodes for BFI at k=5")
  expect_true(all(nodes$prune_reason[nodes$pruned] == "redundant"),
    info = "Flagged nodes should carry prune_reason = 'redundant'"
  )
  expect_true(all(is.na(nodes$prune_reason[!nodes$pruned])),
    info = "Unflagged nodes should have NA prune_reason"
  )

  # Inline-derivation helpers used in the prose (flagged_ids + lvl_summary)
  flagged_ids <- nodes$id[nodes$pruned]
  expect_equal(length(flagged_ids), n_redundant)

  lvl_summary <- paste(
    vapply(
      sort(unique(nodes$level[nodes$pruned])),
      function(L) {
        ids_L <- nodes$id[nodes$pruned & nodes$level == L]
        if (length(ids_L) == sum(nodes$level == L)) {
          paste0("the entire k = ", L, " level")
        } else {
          paste0(length(ids_L), " factor at k = ", L)
        }
      },
      character(1)
    ),
    collapse = ", "
  )
  expect_type(lvl_summary, "character")
  expect_true(nzchar(lvl_summary))
  # On BFI k=5 the whole k=4 level is flagged, so the phrase must appear
  expect_match(lvl_summary, "the entire k = 4 level", fixed = TRUE)
})

test_that("forbes vignette: prune-artifact table idiom returns expected columns", {
  # Guards the prune-artifact-nodes chunk in ackwards-forbes.Rmd. Reuses the
  # shared "poly5_all" base fit (see note in the prune-nodes test above).
  skip_if_not_installed("psych")
  x_base <- cached(ackwards(.bfi_cc, k_max = 5, cor = "polychoric", pairs = "all"))
  x_art <- suppressMessages(prune(x_base, "artifact"))

  art_nodes <- tidy(x_art, what = "nodes")
  n_artifact <- sum(art_nodes$pruned)

  expect_named(art_nodes, c("id", "level", "pruned", "prune_reason"), ignore.order = TRUE)
  expect_type(n_artifact, "integer")
  # Artifact is never auto-flagged (DECISIONS D-012); BFI flags nothing
  expect_equal(n_artifact, 0L)
})

test_that("forbes vignette: skip-edge inline-R values are computed correctly", {
  # Guards the edge-counts and top-skip-edge inline R in ackwards-forbes.Rmd
  skip_if_not_installed("psych")
  x_adj <- cached(ackwards(.bfi_cc, k_max = 5, cor = "polychoric"))
  x_all <- cached(ackwards(.bfi_cc, k_max = 5, cor = "polychoric", pairs = "all"))

  n_adj <- nrow(tidy(x_adj, what = "edges"))
  n_all <- nrow(tidy(x_all, what = "edges"))
  expect_true(n_adj > 0L)
  expect_true(n_all > n_adj)

  edges <- tidy(x_all, what = "edges", sort = "strength")
  skip <- edges[
    abs(edges$level_to - edges$level_from) > 1 & abs(edges$r) >= 0.5,
    c("from", "to", "level_from", "level_to", "r")
  ]
  expect_true(nrow(skip) > 0L)

  top_r <- round(skip$r[1], 2)
  expect_true(abs(top_r) >= 0.5)
  expect_true(abs(skip$level_to[1] - skip$level_from[1]) > 1)
})

test_that("vignette table idiom: knitr::kable fallback renders without gt", {
  # Guards the `else { knitr::kable(...) }` branch of every comparison chunk.
  # gt is installed in dev/CI so that branch never runs during vignette builds;
  # this exercises the fallback idiom directly so it cannot rot unnoticed.
  skip_if_not_installed("psych")
  x_pca <- cached(ackwards(.bfi_cc, k_max = 3, cor = "polychoric"))
  x_efa <- cached(ackwards(.bfi_cc, k_max = 3, engine = "efa", cor = "polychoric"))

  pca_e <- tidy(x_pca, what = "edges", primary_only = TRUE)
  efa_e <- tidy(x_efa, what = "edges", primary_only = TRUE)
  wide_e <- merge(pca_e[, c("from", "to", "r")], efa_e[, c("from", "to", "r")],
    by = c("from", "to"), all = TRUE, suffixes = c("_pca", "_efa")
  )
  wide_e$delta <- abs(wide_e$r_efa) - abs(wide_e$r_pca)

  out <- knitr::kable(
    wide_e,
    digits = 2, row.names = FALSE,
    col.names = c("From", "To", "PCA", "EFA", "Δ (|EFA| - |PCA|)")
  )
  expect_s3_class(out, "knitr_kable")
  expect_true(length(out) > 0L)
})

test_that("vignette edges idiom: disagreeing primary parents surface as NA in the merge", {
  # Guards the documented behaviour behind the edge-table footnotes: when two
  # engines/bases pick a different primary parent for the same child, the
  # all = TRUE merge yields NA rather than silently dropping the row. BFI never
  # triggers this, so demonstrate it on synthetic edge frames.
  a <- data.frame(from = c("m1f1", "m1f1"), to = c("m2f1", "m2f2"), r = c(0.9, 0.4))
  b <- data.frame(from = c("m1f1", "m1f2"), to = c("m2f1", "m2f2"), r = c(0.8, 0.5))

  wide <- merge(a, b,
    by = c("from", "to"), all = TRUE, suffixes = c("_a", "_b")
  )
  wide$delta <- abs(wide$r_b) - abs(wide$r_a)

  # m2f2 has from = m1f1 in `a` but from = m1f2 in `b`: two rows, each half-NA
  disagreement <- wide[wide$to == "m2f2", ]
  expect_equal(nrow(disagreement), 2L)
  expect_true(anyNA(disagreement$r_a))
  expect_true(anyNA(disagreement$r_b))
})

Try the ackwards package in your browser

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

ackwards documentation built on July 25, 2026, 1:08 a.m.