tests/testthat/test-legacy-contracts.R

# Legacy Contract Tests - Output Invariants
# WS2.2: Lock output schemas so refactors cannot silently change user-visible behavior

# These tests verify behavioral contracts that must hold across all releases.
# They serve as guardrails to prevent accidental breaking changes.


# Column Name Invariants -------------------------------------------------
# Fail if columns are renamed or removed from legacy API output

test_that("FitCurves column names are stable (HS equation)", {
  data(apt, package = "beezdemand")
  apt_test <- apt[apt$id == 19, ]

  result <- suppressMessages(suppressWarnings(
    FitCurves(apt_test, equation = "hs", k = 2)
  ))

  # Core parameter columns must exist
  core_params <- c("id", "Q0d", "Alpha", "K", "R2")
  expect_true(all(core_params %in% names(result)),
    info = paste(
      "Core parameter columns missing:",
      paste(setdiff(core_params, names(result)), collapse = ", ")
    )
  )

  # Standard error columns must exist
  se_cols <- c("Q0se", "Alphase")
  expect_true(all(se_cols %in% names(result)),
    info = paste(
      "Standard error columns missing:",
      paste(setdiff(se_cols, names(result)), collapse = ", ")
    )
  )

  # Derived metrics columns must exist
  derived_cols <- c("EV", "Pmaxd", "Omaxd", "Pmaxa", "Omaxa", "Pmaxe", "Omaxe")
  expect_true(all(derived_cols %in% names(result)),
    info = paste(
      "Derived metrics columns missing:",
      paste(setdiff(derived_cols, names(result)), collapse = ", ")
    )
  )

  # Confidence interval columns must exist
  ci_cols <- c("Q0Low", "Q0High", "AlphaLow", "AlphaHigh")
  expect_true(all(ci_cols %in% names(result)),
    info = paste(
      "Confidence interval columns missing:",
      paste(setdiff(ci_cols, names(result)), collapse = ", ")
    )
  )
})

test_that("FitCurves column names are stable (Koff equation)", {
  data(apt, package = "beezdemand")
  apt_test <- apt[apt$id == 19, ]

  result <- suppressMessages(suppressWarnings(
    FitCurves(apt_test, equation = "koff", k = 2)
  ))

  # Same columns as HS
  core_params <- c("id", "Q0d", "Alpha", "K", "R2")
  expect_true(all(core_params %in% names(result)))
})


# Parameter Bound Constraints --------------------------------------------
# Q0 > 0, alpha > 0 for converged models

test_that("Converged models have positive Q0", {
  data(apt, package = "beezdemand")
  apt_test <- apt[apt$id %in% c(19, 30, 38), ]

  result <- suppressMessages(suppressWarnings(
    FitCurves(apt_test, equation = "hs", k = 2)
  ))

  converged <- result[result$Notes == "converged", ]

  # All converged Q0 values should be positive
  expect_true(all(converged$Q0d > 0, na.rm = TRUE),
    info = "All converged Q0d values should be positive"
  )
})

test_that("Converged models have positive alpha", {
  data(apt, package = "beezdemand")
  apt_test <- apt[apt$id %in% c(19, 30, 38), ]

  result <- suppressMessages(suppressWarnings(
    FitCurves(apt_test, equation = "hs", k = 2)
  ))

  converged <- result[result$Notes == "converged", ]

  # All converged Alpha values should be positive
  expect_true(all(converged$Alpha > 0, na.rm = TRUE),
    info = "All converged Alpha values should be positive"
  )
})

test_that("Converged models have R2 in [0, 1] range", {
  data(apt, package = "beezdemand")
  apt_test <- apt[apt$id %in% c(19, 30, 38), ]

  result <- suppressMessages(suppressWarnings(
    FitCurves(apt_test, equation = "hs", k = 2)
  ))

  converged <- result[result$Notes == "converged", ]

  # R2 should be between 0 and 1 for well-fitting models
  # Note: technically R2 can be negative for very poor fits, but converged

  # models should typically have R2 >= 0
  expect_true(all(converged$R2 <= 1, na.rm = TRUE),
    info = "R2 should not exceed 1"
  )
})


# Monotonicity Check -----------------------------------------------------
# Predicted consumption should be non-increasing in price for demand models

test_that("Predicted demand is monotonically non-increasing in price", {
  skip_on_cran()

  data(apt, package = "beezdemand")
  apt_test <- apt[apt$id == 19, ]

  result <- suppressMessages(suppressWarnings(
    FitCurves(apt_test, equation = "hs", k = 2, detailed = TRUE)
  ))

  # Get fitted model for subject 19
  fit_obj <- result[[2]][[1]]
  df <- result[[3]][[1]]

  # Generate predictions at increasing prices
  test_prices <- seq(0.1, 30, by = 0.5)

  # Predict using model formula
  k_val <- result[[1]]$K[1]
  q0_val <- result[[1]]$Q0d[1]
  alpha_val <- result[[1]]$Alpha[1]

  # HS equation: y = q0 * 10^(k * (exp(-alpha * q0 * x) - 1))
  predicted <- q0_val * 10^(k_val * (exp(-alpha_val * q0_val * test_prices) - 1))

  # Check monotonicity: each prediction should be >= next prediction
  diffs <- diff(predicted)
  expect_true(all(diffs <= 1e-10),
    info = paste(
      "Demand should be monotonically decreasing.",
      "Found", sum(diffs > 1e-10), "violations."
    )
  )
})

test_that("Predicted demand (Koff) is monotonically non-increasing", {
  skip_on_cran()

  data(apt, package = "beezdemand")
  apt_test <- apt[apt$id == 19, ]

  result <- suppressMessages(suppressWarnings(
    FitCurves(apt_test, equation = "koff", k = 2)
  ))

  # Get parameter estimates
  k_val <- result$K[1]
  q0_val <- result$Q0d[1]
  alpha_val <- result$Alpha[1]

  # Generate predictions at increasing prices
  test_prices <- seq(0.1, 30, by = 0.5)

  # Koff equation: y = q0 * 10^(k * (exp(-alpha * q0 * x) - 1))
  # Note: Same functional form for predicted values
  predicted <- q0_val * 10^(k_val * (exp(-alpha_val * q0_val * test_prices) - 1))

  # Check monotonicity
  diffs <- diff(predicted)
  expect_true(all(diffs <= 1e-10),
    info = "Koff demand should be monotonically decreasing."
  )
})


# Convergence Status Format Stability ------------------------------------

test_that("Notes column uses consistent convergence format", {
  data(apt, package = "beezdemand")
  apt_test <- apt[apt$id %in% c(19, 30, 38), ]

  result <- suppressMessages(suppressWarnings(
    FitCurves(apt_test, equation = "hs", k = 2)
  ))

  # Notes should contain recognizable status strings
  valid_statuses <- c("converged", "maxiter", "singular", "failed", "")

  # All notes should be character
  expect_type(result$Notes, "character")

  # Check that converged subjects have "converged" status
  converged_rows <- which(!is.na(result$Q0d) & !is.na(result$Alpha))
  if (length(converged_rows) > 0) {
    expect_true(
      any(grepl("converged", result$Notes[converged_rows], ignore.case = TRUE)),
      info = "At least some successful fits should have 'converged' in Notes"
    )
  }
})


# EV Column Invariant -----------------------------------------------------

test_that("EV (Essential Value) is calculated for converged models", {
  skip_on_cran()

  data(apt, package = "beezdemand")
  apt_test <- apt[apt$id %in% c(19, 30, 38), ]

  result <- suppressMessages(suppressWarnings(
    FitCurves(apt_test, equation = "hs", k = 2)
  ))

  converged <- result[result$Notes == "converged", ]

  # EV should be present and positive for converged models
  expect_true(all(!is.na(converged$EV)),
    info = "EV should be calculated for converged models"
  )
  expect_true(all(converged$EV > 0, na.rm = TRUE),
    info = "EV should be positive for converged models"
  )
})


# Row Count Invariants ---------------------------------------------------

test_that("FitCurves returns one row per subject", {
  data(apt, package = "beezdemand")
  apt_test <- apt[apt$id %in% c(19, 30, 38), ]

  result <- suppressMessages(suppressWarnings(
    FitCurves(apt_test, equation = "hs", k = 2)
  ))

  expect_equal(nrow(result), 3)
  expect_equal(sort(as.character(unique(result$id))), sort(c("19", "30", "38")))
})

test_that("FitMeanCurves returns exactly one row", {
  data(apt, package = "beezdemand")
  apt_test <- apt[apt$id %in% c(19, 30, 38), ]

  result_mean <- suppressMessages(suppressWarnings(
    FitMeanCurves(apt_test, equation = "hs", k = 2, method = "Mean")
  ))

  result_pooled <- suppressMessages(suppressWarnings(
    FitMeanCurves(apt_test, equation = "hs", k = 2, method = "Pooled")
  ))

  expect_equal(nrow(result_mean), 1)
  expect_equal(nrow(result_pooled), 1)
})


# Detailed Output Contract -----------------------------------------------

test_that("FitCurves detailed=TRUE returns named list with expected elements", {
  data(apt, package = "beezdemand")
  apt_test <- apt[apt$id %in% c(19, 30), ]

  result <- suppressMessages(suppressWarnings(
    FitCurves(apt_test, equation = "hs", k = 2, detailed = TRUE)
  ))

  expect_type(result, "list")
  # detailed=TRUE returns list with: dfres, fits, newdats, adfs
  expect_true(length(result) >= 3,
    info = "detailed=TRUE should return at least 3 elements"
  )

  # Check named elements exist
  expect_true("dfres" %in% names(result) || is.data.frame(result[[1]]),
    info = "First element should be results data frame"
  )
  expect_true("fits" %in% names(result) || is.list(result[[2]]),
    info = "Second element should be list of model fits"
  )

  # First element: data frame of results
  expect_s3_class(result$dfres %||% result[[1]], "data.frame")

  # Second element: list of model objects (one per subject)
  fits_element <- result$fits %||% result[[2]]
  expect_type(fits_element, "list")
  expect_equal(length(fits_element), 2)  # One per subject
})


# N Column Contract ------------------------------------------------------

test_that("N column reflects number of data points per subject", {
  data(apt, package = "beezdemand")
  apt_test <- apt[apt$id == 19, ]

  result <- suppressMessages(suppressWarnings(
    FitCurves(apt_test, equation = "koff", k = 2)
  ))

  # N should be the number of non-NA observations
  expected_n <- sum(!is.na(apt_test$y))

  expect_equal(result$N[1], expected_n)
})

Try the beezdemand package in your browser

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

beezdemand documentation built on March 3, 2026, 9:07 a.m.