tests/testthat/test_design.R

context("Testing cbc_design()")

# =============================================================================
# TEST SETUP AND FIXTURES
# =============================================================================

# Create shared test fixtures to avoid repeated profile creation
setup_test_profiles <- function() {
  cbc_profiles(
    price = c(1, 2, 3),
    type = c("Fuji", "Gala", "Honeycrisp"),
    freshness = c("Poor", "Average", "Excellent"),
    weight = c(0.5, 1.0, 1.5) # Continuous variable
  )
}

# Small test parameters for speed
fast_params <- list(
  n_alts = 3,
  n_q = 6,
  n_resp = 10,
  max_iter = 3,
  n_start = 2
)

# Create different prior types for testing
setup_test_priors <- function(profiles, type = "simple") {
  switch(
    type,
    "simple" = cbc_priors(
      profiles = profiles,
      price = -0.1,
      type = c("Gala" = 0.2, "Honeycrisp" = 0.3),
      freshness = c("Average" = 0.1, "Excellent" = 0.4),
      weight = 0.05
    ),
    "nochoice" = cbc_priors(
      profiles = profiles,
      price = -0.1,
      type = c("Gala" = 0.2, "Honeycrisp" = 0.3),
      freshness = c("Average" = 0.1, "Excellent" = 0.4),
      weight = 0.05,
      no_choice = -1.0
    ),
    "interactions" = cbc_priors(
      profiles = profiles,
      price = -0.1,
      type = c("Gala" = 0.2, "Honeycrisp" = 0.3),
      freshness = c("Average" = 0.1, "Excellent" = 0.4),
      weight = 0.05,
      interactions = list(
        int_spec(
          between = c("price", "type"),
          with_level = "Gala",
          value = 0.1
        ),
        int_spec(
          between = c("type", "freshness"),
          level = "Gala",
          with_level = "Average",
          value = 0.2
        )
      )
    ),
    "random" = cbc_priors(
      profiles = profiles,
      price = rand_spec("n", mean = -0.1, sd = 0.05),
      type = c("Gala" = 0.2, "Honeycrisp" = 0.3),
      freshness = c("Average" = 0.1, "Excellent" = 0.4),
      weight = 0.05,
      n_draws = 20 # Small for testing
    )
  )
}

# =============================================================================
# DESIGN VALIDATION HELPERS
# =============================================================================

# Comprehensive design validation function
validate_design_structure <- function(design, expected_params) {
  params <- attr(design, "design_params")
  summary_info <- attr(design, "design_summary")

  # Basic class and structure
  expect_s3_class(design, "cbc_design")
  expect_s3_class(design, "data.frame")

  # Required columns
  required_cols <- c("profileID", "respID", "qID", "altID", "obsID")
  if (params$n_blocks > 1) {
    required_cols <- c(required_cols, "blockID")
  }
  expect_true(all(required_cols %in% names(design)))

  # Dimensions
  expected_rows <- expected_params$n_resp *
    expected_params$n_q *
    (expected_params$n_alts + ifelse(expected_params$no_choice, 1, 0))
  expect_equal(nrow(design), expected_rows)

  # Parameter consistency
  expect_equal(params$n_q, expected_params$n_q)
  expect_equal(params$n_alts, expected_params$n_alts)
  expect_equal(params$n_resp, expected_params$n_resp)
  expect_equal(params$no_choice, expected_params$no_choice)

  # Metadata presence
  expect_type(summary_info, "list")
  expect_true("n_profiles_used" %in% names(summary_info))
  expect_true("profile_usage_rate" %in% names(summary_info))

  # ID column integrity
  expect_true(all(design$respID %in% 1:expected_params$n_resp))
  expect_true(all(design$qID %in% 1:expected_params$n_q))
  expect_equal(max(design$obsID), expected_params$n_resp * expected_params$n_q)

  # No duplicate profiles within questions
  for (obs in unique(design$obsID)) {
    obs_profiles <- design$profileID[design$obsID == obs]
    obs_profiles <- obs_profiles[obs_profiles != 0] # Exclude no-choice
    expect_equal(
      length(obs_profiles),
      length(unique(obs_profiles)),
      info = paste("Duplicate profiles in observation", obs)
    )
  }
}

# Validate no-choice specific features
validate_nochoice_features <- function(design) {
  if (attr(design, "design_params")$no_choice) {
    # Should have no-choice rows
    expect_true(any(design$profileID == 0))
    expect_true("no_choice" %in% names(design))
    expect_true(all(design$no_choice[design$profileID == 0] == 1))
    expect_true(all(design$no_choice[design$profileID != 0] == 0))
  } else {
    # Should not have no-choice features
    expect_false(any(design$profileID == 0))
    expect_false("no_choice" %in% names(design))
  }
}

# Validate D-error calculations for optimal methods
validate_d_errors <- function(design, method) {
  params <- attr(design, "design_params")

  if (method %in% c("stochastic", "modfed", "cea")) {
    # Should have null D-error
    expect_true(!is.null(params$d_error_null))
    expect_true(is.numeric(params$d_error_null))
    expect_true(is.finite(params$d_error_null))
    expect_true(params$d_error_null > 0)

    # Should have prior D-error if priors were used
    if (!is.null(attr(design, "priors"))) {
      expect_true(!is.null(params$d_error_prior))
      expect_true(is.numeric(params$d_error_prior))
      expect_true(is.finite(params$d_error_prior))
      expect_true(params$d_error_prior > 0)
    }
  }
}

# Validate interaction handling
validate_interactions <- function(design, has_interactions) {
  params <- attr(design, "design_params")

  if (has_interactions) {
    expect_true(params$has_interactions)
    expect_true(params$n_interactions > 0)
  } else {
    expect_false(params$has_interactions %||% FALSE)
    expect_equal(params$n_interactions %||% 0, 0)
  }
}

# =============================================================================
# BASIC FUNCTIONALITY TESTS
# =============================================================================

test_that("Random design with no priors works", {
  skip_on_cran() # Skip on CRAN due to computation time
  profiles <- setup_test_profiles()

  design <- cbc_design(
    profiles = profiles,
    method = "random",
    n_alts = fast_params$n_alts,
    n_q = fast_params$n_q,
    n_resp = fast_params$n_resp
  )

  expected <- list(
    n_alts = fast_params$n_alts,
    n_q = fast_params$n_q,
    n_resp = fast_params$n_resp,
    no_choice = FALSE
  )

  validate_design_structure(design, expected)
  validate_nochoice_features(design)
  expect_equal(attr(design, "design_params")$method, "random")
})

test_that("Random design with no-choice works", {
  skip_on_cran() # Skip on CRAN due to computation time
  profiles <- setup_test_profiles()

  design <- cbc_design(
    profiles = profiles,
    method = "random",
    n_alts = fast_params$n_alts,
    n_q = fast_params$n_q,
    n_resp = fast_params$n_resp,
    no_choice = TRUE
  )

  expected <- list(
    n_alts = fast_params$n_alts,
    n_q = fast_params$n_q,
    n_resp = fast_params$n_resp,
    no_choice = TRUE
  )

  validate_design_structure(design, expected)
  validate_nochoice_features(design)
})

# =============================================================================
# METHOD-SPECIFIC TESTS
# =============================================================================
# These are skipped on CRAN as they take too long to run

test_that("Greedy methods work with simple priors", {
  skip_on_cran() # Skip on CRAN due to computation time
  profiles <- setup_test_profiles()
  priors <- setup_test_priors(profiles, "simple")

  methods <- c("shortcut", "minoverlap", "balanced")

  for (method in methods) {
    design <- cbc_design(
      profiles = profiles,
      priors = priors,
      method = method,
      n_alts = fast_params$n_alts,
      n_q = fast_params$n_q,
      n_resp = fast_params$n_resp
    )

    expected <- list(
      n_alts = fast_params$n_alts,
      n_q = fast_params$n_q,
      n_resp = fast_params$n_resp,
      no_choice = FALSE
    )

    validate_design_structure(design, expected)
    expect_equal(attr(design, "design_params")$method, method)
  }
})

test_that("Optimal methods work with simple priors", {
  skip_on_cran() # Skip on CRAN due to computation time
  skip_if_not_installed("idefix")

  profiles <- setup_test_profiles()
  priors <- setup_test_priors(profiles, "simple")

  methods <- c("stochastic", "modfed", "cea")

  for (method in methods) {
    design <- cbc_design(
      profiles = profiles,
      priors = priors,
      method = method,
      n_alts = fast_params$n_alts,
      n_q = fast_params$n_q,
      n_resp = fast_params$n_resp,
      max_iter = fast_params$max_iter,
      n_start = fast_params$n_start,
      use_idefix = TRUE
    )

    expected <- list(
      n_alts = fast_params$n_alts,
      n_q = fast_params$n_q,
      n_resp = fast_params$n_resp,
      no_choice = FALSE
    )

    validate_design_structure(design, expected)
    validate_d_errors(design, method)
    expect_equal(attr(design, "design_params")$method, method)
  }
})

# =============================================================================
# FEATURE-SPECIFIC TESTS
# =============================================================================

test_that("No-choice option works across methods", {
  skip_on_cran() # Skip on CRAN due to computation time
  profiles <- setup_test_profiles()
  priors <- setup_test_priors(profiles, "nochoice")

  # Test different methods
  methods <- c("random", "shortcut", "stochastic")

  for (method in methods) {
    # Skip stochastic if idefix not available
    if (method == "stochastic" && !requireNamespace("idefix", quietly = TRUE)) {
      skip("idefix not available")
    }

    design <- cbc_design(
      profiles = profiles,
      priors = if (method == "random") NULL else priors,
      method = method,
      n_alts = fast_params$n_alts,
      n_q = fast_params$n_q,
      n_resp = fast_params$n_resp,
      no_choice = TRUE,
      max_iter = if (method == "stochastic") fast_params$max_iter else NULL,
      n_start = if (method == "stochastic") fast_params$n_start else NULL
    )

    expected <- list(
      n_alts = fast_params$n_alts,
      n_q = fast_params$n_q,
      n_resp = fast_params$n_resp,
      no_choice = TRUE
    )

    validate_design_structure(design, expected)
    validate_nochoice_features(design)
  }
})

test_that("Interaction terms work correctly", {
  skip_on_cran() # Skip on CRAN due to computation time
  skip_if_not_installed("idefix")

  profiles <- setup_test_profiles()
  priors <- setup_test_priors(profiles, "interactions")

  design <- cbc_design(
    profiles = profiles,
    priors = priors,
    method = "stochastic",
    n_alts = fast_params$n_alts,
    n_q = fast_params$n_q,
    n_resp = fast_params$n_resp,
    max_iter = fast_params$max_iter,
    n_start = fast_params$n_start,
    use_idefix = FALSE # Use cbcTools implementation for more control
  )

  expected <- list(
    n_alts = fast_params$n_alts,
    n_q = fast_params$n_q,
    n_resp = fast_params$n_resp,
    no_choice = FALSE
  )

  validate_design_structure(design, expected)
  validate_interactions(design, TRUE)
})

test_that("Random parameters work correctly", {
  skip_on_cran() # Skip on CRAN due to computation time
  skip_if_not_installed("idefix")

  profiles <- setup_test_profiles()
  priors <- setup_test_priors(profiles, "random")

  design <- cbc_design(
    profiles = profiles,
    priors = priors,
    method = "stochastic",
    n_alts = fast_params$n_alts,
    n_q = fast_params$n_q,
    n_resp = fast_params$n_resp,
    max_iter = fast_params$max_iter,
    n_start = fast_params$n_start,
    use_idefix = FALSE
  )

  expected <- list(
    n_alts = fast_params$n_alts,
    n_q = fast_params$n_q,
    n_resp = fast_params$n_resp,
    no_choice = FALSE
  )

  validate_design_structure(design, expected)
  validate_d_errors(design, "stochastic")
})

test_that("Blocking works correctly", {
  skip_on_cran() # Skip on CRAN due to computation time
  skip_if_not_installed("idefix")

  profiles <- setup_test_profiles()
  priors <- setup_test_priors(profiles, "simple")

  design <- cbc_design(
    profiles = profiles,
    priors = priors,
    method = "stochastic",
    n_alts = fast_params$n_alts,
    n_q = fast_params$n_q,
    n_resp = fast_params$n_resp,
    n_blocks = 2,
    max_iter = fast_params$max_iter,
    n_start = fast_params$n_start,
    use_idefix = FALSE
  )

  # Should have blockID column
  expect_true("blockID" %in% names(design))
  expect_true(all(design$blockID %in% 1:2))
  expect_equal(attr(design, "design_params")$n_blocks, 2)
})

# =============================================================================
# LABELED DESIGN TESTS
# =============================================================================

test_that("Labeled designs work correctly", {
  skip_on_cran() # Skip on CRAN due to computation time
  # Create profiles with label attribute
  labeled_profiles <- cbc_profiles(
    price = c(1, 2, 3),
    type = c("A", "B"),
    brand = c("X", "Y") # This will be our label
  )

  priors <- cbc_priors(
    profiles = labeled_profiles,
    price = -0.1,
    type = c("B" = 0.2),
    brand = c("Y" = 0.3)
  )

  design <- cbc_design(
    profiles = labeled_profiles,
    priors = priors,
    method = "random",
    n_alts = 2, # Must match number of label levels
    n_q = fast_params$n_q,
    n_resp = fast_params$n_resp,
    label = "brand"
  )

  # Validate that each question has one profile from each brand
  for (obs in unique(design$obsID)) {
    obs_data <- design[design$obsID == obs, ]
    obs_profiles <- obs_data$profileID[obs_data$profileID != 0]
    profile_brands <- labeled_profiles$brand[
      labeled_profiles$profileID %in% obs_profiles
    ]
    expect_equal(length(unique(profile_brands)), 2)
  }

  expect_equal(attr(design, "design_params")$label, "brand")
})

# =============================================================================
# ERROR HANDLING TESTS
# =============================================================================

test_that("Input validation works correctly", {
  skip_on_cran() # Skip on CRAN due to computation time
  profiles <- setup_test_profiles()

  # Invalid method
  expect_error(
    cbc_design(profiles, method = "invalid"),
    "method must be one of"
  )

  # n_alts too large
  expect_error(
    cbc_design(profiles, method = "random", n_alts = 100, n_q = 4, n_resp = 5),
    "n_alts.*cannot be larger"
  )

  # Invalid no_choice without priors
  priors_no_nochoice <- setup_test_priors(profiles, "simple")
  expect_error(
    cbc_design(
      profiles,
      priors = priors_no_nochoice,
      method = "stochastic",
      n_alts = 2,
      n_q = 4,
      n_resp = 5,
      no_choice = TRUE
    ),
    "no_choice.*requires priors"
  )
})

# =============================================================================
# PERFORMANCE REGRESSION TESTS
# =============================================================================

test_that("Design generation completes in reasonable time", {
  skip_on_cran() # Skip on CRAN due to computation time
  profiles <- setup_test_profiles()
  priors <- setup_test_priors(profiles, "simple")

  # Random should be very fast
  expect_lt(
    system.time({
      cbc_design(profiles, method = "random", n_alts = 2, n_q = 6, n_resp = 10)
    })[["elapsed"]],
    2 # Should complete in under 2 seconds
  )

  # Greedy methods should be reasonably fast
  expect_lt(
    system.time({
      cbc_design(
        profiles,
        priors = priors,
        method = "shortcut",
        n_alts = 2,
        n_q = 6,
        n_resp = 10
      )
    })[["elapsed"]],
    5 # Should complete in under 5 seconds
  )
})

# =============================================================================
# INTEGRATION TESTS
# =============================================================================

test_that("Design integrates properly with cbc_choices", {
  skip_on_cran() # Skip on CRAN due to computation time
  profiles <- setup_test_profiles()
  priors <- setup_test_priors(profiles, "simple")

  design <- cbc_design(
    profiles = profiles,
    priors = priors,
    method = "random",
    n_alts = fast_params$n_alts,
    n_q = fast_params$n_q,
    n_resp = fast_params$n_resp
  )

  # Should work with choice simulation
  choices <- cbc_choices(design, priors)

  expect_s3_class(choices, "cbc_choices")
  expect_equal(nrow(choices), nrow(design))
  expect_true("choice" %in% names(choices))
  expect_true(all(choices$choice %in% c(0, 1)))
})

test_that("Design works with cbc_inspect", {
  skip_on_cran() # Skip on CRAN due to computation time
  profiles <- setup_test_profiles()
  priors <- setup_test_priors(profiles, "simple")

  design <- cbc_design(
    profiles = profiles,
    priors = priors,
    method = "shortcut",
    n_alts = fast_params$n_alts,
    n_q = fast_params$n_q,
    n_resp = fast_params$n_resp
  )

  # Should work with inspection
  inspection <- cbc_inspect(design)

  expect_s3_class(inspection, "cbc_inspection")
  expect_true("structure" %in% names(inspection))
})

Try the cbcTools package in your browser

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

cbcTools documentation built on Aug. 21, 2025, 6:03 p.m.