tests/testthat/test-kk.R

# Test file for kk.R
# Tests for kk_nowcast and related functions
#' @srrstats {G5.2} Error and warning behavior tested
#' @srrstats {G5.2a} Every error message is unique
#' @srrstats {G5.2b} Tests demonstrate conditions triggering messages
#' @srrstats {G5.4} Correctness tests against fixed test data
#' @srrstats {G5.5} Tests run with fixed random seed
#' @srrstats {G5.6} Parameter recovery tests

# ===== Setup Test Data =====

set.seed(456)
n_obs <- 30
n_releases <- 3

# Generate synthetic data for KK model
# True values with AR(1) process
ar_coef <- 0.7
true_values <- numeric(n_obs)
true_values[1] <- rnorm(1)
for (t in 2:n_obs) {
  true_values[t] <- ar_coef * true_values[t - 1] + rnorm(1, 0, 0.5)
}

# Create release data with systematic revisions
df_wide_kk <- data.frame(
  time = seq.Date(
    from = as.Date("2020-01-01"),
    by = "month",
    length.out = n_obs
  )
)

# Add releases with decreasing measurement error
for (i in 0:(n_releases - 1)) {
  noise_sd <- 0.5 / (i + 1) # Noise decreases with newer releases
  df_wide_kk[[paste0("release_", i)]] <- true_values + rnorm(n_obs, 0, noise_sd)
}

# Create long format version
df_long_kk <- tidyr::pivot_longer(
  df_wide_kk,
  cols = -time,
  names_to = "release",
  values_to = "value"
)

# Smaller dataset for faster tests
df_small <- df_wide_kk[1:20, ]

kk_fit <- function(key, ..., solver_options = list(trace = 0)) {
  args <- list(...)
  args$solver_options <- modifyList(list(trace = 0), solver_options)

  cached_fixture(
    paste0("kk-", key),
    function() do.call(kk_nowcast, args)
  )
}

fit_kk_ols <- function() {
  kk_fit(
    "ols",
    df = df_small,
    e = 1,
    h = 0,
    model = "KK",
    method = "OLS"
  )
}

fit_kk_ols_forecast <- function() {
  kk_fit(
    "ols-forecast",
    df = df_small,
    e = 1,
    h = 3,
    model = "KK",
    method = "OLS"
  )
}

fit_kk_sur <- function() {
  kk_fit(
    "sur",
    df = df_small,
    e = 1,
    h = 0,
    model = "KK",
    method = "SUR"
  )
}

fit_kk_mle <- function() {
  kk_fit(
    "mle",
    df = df_small,
    e = 1,
    h = 0,
    model = "KK",
    method = "MLE",
    solver_options = list(maxiter = 40, return_states = FALSE)
  )
}

fit_kk_long <- function() {
  kk_fit(
    "long",
    df = df_long_kk[df_long_kk$time %in% df_small$time, ],
    e = 1,
    h = 0,
    model = "KK",
    method = "OLS"
  )
}

fit_kk_howrey <- function() {
  kk_fit(
    "howrey",
    df = df_small,
    e = 1,
    h = 0,
    model = "Howrey",
    method = "OLS"
  )
}

fit_kk_classical <- function() {
  kk_fit(
    "classical",
    df = df_small,
    e = 1,
    h = 0,
    model = "Classical",
    method = "OLS"
  )
}

fit_kk_e2 <- function() {
  kk_fit(
    "e2",
    df = df_small,
    e = 2,
    h = 0,
    model = "KK",
    method = "OLS"
  )
}

# ===== Tests for kk_nowcast =====

test_that("kk_nowcast returns correct structure with SUR", {
  result <- fit_kk_sur()

  expect_s3_class(result, "kk_model")
  expect_true("states" %in% names(result))
  expect_true("params" %in% names(result))
  expect_true("kk_model_mat" %in% names(result))
  expect_true("ss_model_mat" %in% names(result))
  expect_true("fit" %in% names(result))
  expect_true("e" %in% names(result))
  expect_equal(result$e, 1)
})

test_that("kk_nowcast returns correct structure with OLS", {
  result <- fit_kk_ols()

  expect_s3_class(result, "kk_model")
  expect_true("params" %in% names(result))
  expect_equal(result$convergence, 0)
})

test_that("kk_nowcast returns correct structure with MLE", {
  result <- fit_kk_mle()

  expect_s3_class(result, "kk_model")
  expect_true(!is.null(result$loglik))
  expect_true(!is.null(result$aic))
  expect_true(!is.null(result$bic))
  expect_true(is.finite(result$loglik))
})

test_that("kk_nowcast handles long format data", {
  result <- fit_kk_long()

  expect_s3_class(result, "kk_model")
})

test_that("kk_nowcast validates e parameter", {
  expect_error(
    kk_nowcast(df = df_small, e = 0, model = "KK"),
    "'e' must be a single whole number greater than 0"
  )

  expect_error(
    kk_nowcast(df = df_small, e = 3, model = "KK"),
    "'df' must contain release columns"
  )
})

test_that("kk_nowcast validates h parameter", {
  expect_error(
    kk_nowcast(df = df_small, e = 1, h = -1, model = "KK"),
    "'h' must be at least 0"
  )
})

test_that("kk_nowcast validates model parameter", {
  expect_error(
    kk_nowcast(df = df_small, e = 1, model = "invalid"),
    "'model' must be one of"
  )
})

test_that("kk_nowcast validates solver_options parameter", {
  expect_error(
    kk_nowcast(df = df_small, e = 1, solver_options = "not_a_list"),
    "'solver_options' must be a list"
  )
})

test_that("kk_nowcast validates solver_options names", {
  expect_error(
    kk_nowcast(
      df = df_small,
      e = 1,
      solver_options = list(invalid_option = 123)
    ),
    "Invalid solver options provided"
  )
})

test_that("kk_nowcast handles Kishor-Koenig model", {
  result <- kk_nowcast(
    df = df_small,
    e = 1,
    model = "Kishor-Koenig",
    method = "OLS",
    solver_options = list(trace = 0)
  )

  expect_s3_class(result, "kk_model")
  expect_true(any(grepl("^G", result$params$Parameter)))
})

test_that("kk_nowcast handles KK model (short name)", {
  result <- kk_nowcast(
    df = df_small,
    e = 1,
    model = "kk",
    method = "OLS",
    solver_options = list(trace = 0)
  )

  expect_s3_class(result, "kk_model")
})

test_that("kk_nowcast handles Howrey model", {
  result <- fit_kk_howrey()

  expect_s3_class(result, "kk_model")
  # Howrey has e^2 G parameters (fewer than KK)
  n_g_params <- sum(grepl("^G", result$params$Parameter))
  expect_equal(n_g_params, 1^2) # e=1, so 1 G parameter
})

test_that("kk_nowcast handles Classical model", {
  result <- fit_kk_classical()

  expect_s3_class(result, "kk_model")
  # Classical has no G parameters
  n_g_params <- sum(grepl("^G", result$params$Parameter))
  expect_equal(n_g_params, 0)
})

test_that("kk_nowcast handles different e values", {
  result_e1 <- fit_kk_ols()
  result_e2 <- fit_kk_e2()

  # e=2 should have more parameters than e=1
  expect_gt(nrow(result_e2$params), nrow(result_e1$params))
})

test_that("kk_nowcast produces forecasts when h > 0", {
  result <- fit_kk_ols_forecast()

  oos_data <- result$states[result$states$sample == "out_of_sample", ]
  expect_gt(nrow(oos_data), 0)
  expect_equal(length(unique(oos_data$time)), 3)
})

test_that("kk_nowcast handles custom starting values with OLS", {
  # Get parameter count
  temp_result <- fit_kk_ols()

  n_params <- nrow(temp_result$params)
  start_vals <- rep(0.3, n_params)
  names(start_vals) <- temp_result$params$Parameter

  result <- kk_nowcast(
    df = df_small,
    e = 1,
    method = "OLS",
    solver_options = list(trace = 0, startvals = start_vals)
  )

  expect_s3_class(result, "kk_model")
})

test_that("kk_nowcast validates startvals length", {
  start_vals <- c(F0 = 0.5, G1_1 = 0.3) # Wrong length

  expect_error(
    kk_nowcast(
      df = df_small,
      e = 1,
      method = "OLS",
      solver_options = list(startvals = start_vals)
    ),
    "The length of 'startvals' must be"
  )
})

test_that("kk_nowcast validates startvals are numeric", {
  expect_error(
    kk_nowcast(
      df = df_small,
      e = 1,
      solver_options = list(startvals = "not_numeric")
    ),
    "'startvals' must be a numeric vector"
  )
})

test_that("kk_nowcast accepts unnamed startvals in canonical order", {
  skip_if_not_reviser_full_tests()
  start_vals <- c(0.5, 0.3, 0.2, 0.1, 0.1)

  result <- kk_nowcast(
    df = df_small,
    e = 1,
    method = "MLE",
    solver_options = list(
      trace = 0,
      startvals = start_vals,
      maxiter = 30,
      return_states = FALSE
    )
  )

  expect_s3_class(result, "kk_model")
  expect_true(is.finite(result$loglik))
})

test_that("kk_nowcast MLE with different optimization methods", {
  skip_if_not_reviser_full_tests()
  methods <- c("L-BFGS-B", "BFGS", "Nelder-Mead", "nlminb")

  for (meth in methods) {
    result <- kk_nowcast(
      df = df_small,
      e = 1,
      method = "MLE",
      solver_options = list(
        trace = 0,
        method = meth,
        maxiter = 40,
        return_states = FALSE
      )
    )

    expect_s3_class(result, "kk_model")
    expect_true(is.finite(result$loglik))
  }
})

test_that("kk_nowcast MLE with two-step method", {
  skip_if_not_reviser_full_tests()
  result <- kk_nowcast(
    df = df_small,
    e = 1,
    method = "MLE",
    solver_options = list(
      trace = 0,
      method = "two-step",
      maxiter = 40,
      return_states = FALSE
    )
  )

  expect_s3_class(result, "kk_model")
  expect_true(is.finite(result$loglik))
})

test_that("kk_nowcast MLE with multi-start optimization", {
  skip_if_not_reviser_full_tests()
  result <- kk_nowcast(
    df = df_small,
    e = 1,
    method = "MLE",
    solver_options = list(
      trace = 0,
      n_starts = 2,
      maxiter = 40,
      seed = 123,
      return_states = FALSE
    )
  )

  expect_s3_class(result, "kk_model")
})

test_that("kk_nowcast MLE with transform_se option", {
  skip_if_not_reviser_full_tests()
  result_transform <- kk_nowcast(
    df = df_small,
    e = 1,
    method = "MLE",
    solver_options = list(
      trace = 0,
      transform_se = TRUE,
      maxiter = 40,
      return_states = FALSE
    )
  )

  result_no_transform <- kk_nowcast(
    df = df_small,
    e = 1,
    method = "MLE",
    solver_options = list(
      trace = 0,
      transform_se = FALSE,
      maxiter = 40,
      return_states = FALSE
    )
  )

  expect_s3_class(result_transform, "kk_model")
  expect_s3_class(result_no_transform, "kk_model")
})

test_that("kk_nowcast MLE supports se_method none and qml", {
  skip_if_not_reviser_full_tests()
  result_none <- kk_nowcast(
    df = df_small,
    e = 1,
    method = "MLE",
    solver_options = list(
      trace = 0,
      se_method = "none",
      maxiter = 40,
      return_states = FALSE
    )
  )

  result_qml <- kk_nowcast(
    df = df_small,
    e = 1,
    method = "MLE",
    solver_options = list(
      trace = 0,
      se_method = "qml",
      maxiter = 40,
      return_states = FALSE
    )
  )

  expect_true(all(is.na(result_none$params$Std.Error)))
  expect_true(any(is.finite(result_qml$params$Std.Error)))
})

test_that("kk_nowcast can skip state extraction", {
  result <- fit_kk_mle()

  expect_null(result$states)
  expect_null(result$model)
})

test_that("kk_nowcast MLE seed is reproducible and preserves RNG state", {
  skip_if_not_reviser_full_tests()
  baseline_rng <- function() {
    set.seed(2024)
    rnorm(3)
    rnorm(3)
  }

  post_fit_rng <- function() {
    set.seed(2024)
    rnorm(3)
    kk_nowcast(
      df = df_small,
      e = 1,
      method = "MLE",
      solver_options = list(
        trace = 0,
        n_starts = 2,
        seed = 99,
        maxiter = 30,
        return_states = FALSE
      )
    )
    rnorm(3)
  }

  result1 <- kk_nowcast(
    df = df_small,
    e = 1,
    method = "MLE",
    solver_options = list(
      trace = 0,
      n_starts = 2,
      seed = 123,
      maxiter = 30,
      return_states = FALSE
    )
  )

  set.seed(999)
  result2 <- kk_nowcast(
    df = df_small,
    e = 1,
    method = "MLE",
    solver_options = list(
      trace = 0,
      n_starts = 2,
      seed = 123,
      maxiter = 30,
      return_states = FALSE
    )
  )

  expect_identical(baseline_rng(), post_fit_rng())
  expect_equal(
    result1$params$Estimate,
    result2$params$Estimate,
    tolerance = 1e-10
  )
})

test_that("kk_nowcast states output has correct structure", {
  result <- fit_kk_ols_forecast()

  states <- result$states

  expect_true("time" %in% colnames(states))
  expect_true("state" %in% colnames(states))
  expect_true("estimate" %in% colnames(states))
  expect_true("lower" %in% colnames(states))
  expect_true("upper" %in% colnames(states))
  expect_true("filter" %in% colnames(states))
  expect_true("sample" %in% colnames(states))

  expect_true(all(states$filter %in% c("filtered", "smoothed")))
  expect_true(all(states$sample %in% c("in_sample", "out_of_sample")))
})

test_that("kk_nowcast parameter estimates are finite", {
  result <- fit_kk_ols()

  expect_true(all(is.finite(result$params$Estimate)))
})

test_that("kk_nowcast handles irregular time series error", {
  df_irregular <- df_small
  df_irregular$time <- c(
    seq.Date(as.Date("2020-01-01"), by = "month", length.out = 10),
    seq.Date(as.Date("2020-11-01"), by = "quarter", length.out = 10)
  )

  suppressWarnings(
    expect_error(
      kk_nowcast(
        df = df_irregular,
        e = 1,
        h = 2,
        method = "OLS",
        solver_options = list(trace = 0)
      ),
      "not to be regular"
    )
  )
})

test_that("kk_nowcast case insensitivity for model parameter", {
  result_lower <- kk_nowcast(
    df = df_small,
    e = 1,
    model = "kk",
    method = "OLS",
    solver_options = list(trace = 0)
  )

  result_upper <- kk_nowcast(
    df = df_small,
    e = 1,
    model = "KK",
    method = "OLS",
    solver_options = list(trace = 0)
  )

  expect_equal(nrow(result_lower$params), nrow(result_upper$params))
})

test_that("kk_nowcast case insensitivity for method parameter", {
  result_lower <- kk_nowcast(
    df = df_small,
    e = 1,
    method = "ols",
    solver_options = list(trace = 0)
  )

  result_upper <- kk_nowcast(
    df = df_small,
    e = 1,
    method = "OLS",
    solver_options = list(trace = 0)
  )

  expect_equal(nrow(result_lower$params), nrow(result_upper$params))
})

# ===== Tests for kk_matrices =====

test_that("kk_matrices creates character matrices", {
  matrices <- kk_matrices(e = 2, model = "KK", type = "character")

  expect_true("FF" %in% names(matrices))
  expect_true("GG" %in% names(matrices))
  expect_true("V" %in% names(matrices))
  expect_true("W" %in% names(matrices))
  expect_true("params" %in% names(matrices))

  expect_true(is.character(matrices$FF))
  expect_equal(dim(matrices$FF), c(3, 3)) # e+1 x e+1
})

test_that("kk_matrices creates numeric matrices with params", {
  # For e=2, KK model G parameters follow: G(e-i)_(e-j+1)
  # With i in 1:e, j in 1:(e+1):
  # i=1, j=1,2,3: G1_2, G1_1, G1_0
  # i=2, j=1,2,3: G0_2, G0_1, G0_0
  params <- c(
    F0 = 0.8,
    G1_2 = 0.5, G1_1 = 0.4, G1_0 = 0.3,
    G0_2 = 0.4, G0_1 = 0.3, G0_0 = 0.2,
    v0 = 0.1,
    eps1 = 0.05, eps0 = 0.03
  )

  matrices <- kk_matrices(
    e = 2,
    model = "KK",
    params = params,
    type = "numeric"
  )

  expect_true(is.numeric(matrices$FF))
  expect_equal(matrices$FF[3, 3], 0.8) # F0 in bottom right
})

test_that("kk_matrices validates e parameter", {
  expect_error(
    kk_matrices(e = 0, model = "KK", type = "character"),
    "The initial release is already efficient"
  )
})

test_that("kk_matrices validates model parameter", {
  expect_error(
    kk_matrices(e = 1, model = "invalid", type = "character"),
    "'model' must be one of"
  )
})

test_that("kk_matrices validates type parameter", {
  expect_error(
    kk_matrices(e = 1, model = "KK", type = "invalid"),
    "'type' must be one of"
  )
})

test_that("kk_matrices requires params for numeric type", {
  expect_error(
    kk_matrices(e = 1, model = "KK", type = "numeric"),
    "argument 'params' must be provided"
  )
})

test_that("kk_matrices validates params length", {
  params <- c(F0 = 0.8) # Too few parameters

  expect_error(
    kk_matrices(e = 1, model = "KK", params = params, type = "numeric"),
    "'params' must have length"
  )
})

test_that("kk_matrices accepts unnamed params in canonical order", {
  params <- c(0.8, 0.4, 0.5, 0.1, 0.05)

  matrices <- kk_matrices(
    e = 1,
    model = "KK",
    params = params,
    type = "numeric"
  )

  expect_equal(names(matrices$params), c("F0", "G0_0", "G0_1", "v0", "eps0"))
  expect_equal(matrices$FF[2, 2], 0.8)
  expect_equal(matrices$GG[2, 1], 0.5)
  expect_equal(matrices$GG[2, 2], 0.4)
})

test_that("kk_matrices normalizes named params to canonical order", {
  params <- c(
    eps0 = 0.05,
    G0_1 = 0.5,
    F0 = 0.8,
    v0 = 0.1,
    G0_0 = 0.4
  )

  matrices <- kk_matrices(
    e = 1,
    model = "KK",
    params = params,
    type = "numeric"
  )

  expect_equal(
    unname(matrices$params),
    c(0.8, 0.4, 0.5, 0.1, 0.05)
  )
  expect_equal(names(matrices$params), c("F0", "G0_0", "G0_1", "v0", "eps0"))
})

test_that("kk_matrices handles Howrey model correctly", {
  matrices <- kk_matrices(e = 2, model = "Howrey", type = "character")

  # Howrey has e*e G parameters
  n_g_params <- sum(grepl("^G", names(matrices$params)))
  expect_equal(n_g_params, 2^2) # e=2, so 4 G parameters
})

test_that("kk_matrices handles Classical model correctly", {
  matrices <- kk_matrices(e = 2, model = "Classical", type = "character")

  # Classical has no G parameters
  n_g_params <- sum(grepl("^G", names(matrices$params)))
  expect_equal(n_g_params, 0)

  # GG should be identity matrix
  expect_true(all(diag(3) == as.numeric(matrices$GG)))
})

test_that("kk_matrices parameter sorting is consistent", {
  matrices <- kk_matrices(e = 1, model = "KK", type = "character")

  param_names <- names(matrices$params)
  # Should start with F0, then G params, then v0, then eps params
  expect_equal(param_names[1], "F0")
  expect_true(grepl("^v0$", param_names[length(param_names) - 1]))
})

# ===== Tests for kk symbolic helpers =====

test_that("kk_sym_is_zero identifies zero tokens", {
  expect_equal(
    reviser:::kk_sym_is_zero(c("0", "(0)", "1", "(1)", "G0_0", "(0.0)")),
    c(TRUE, TRUE, FALSE, FALSE, FALSE, FALSE)
  )
})

test_that("kk_sym_mul drops terms with a zero operand", {
  expect_equal(reviser:::kk_sym_mul("0", "(G0_0)"), "0")
  expect_equal(reviser:::kk_sym_mul("(F0)", "(0)"), "0")
  expect_equal(reviser:::kk_sym_mul("(F0)", "(x1)"), "(F0) * (x1)")
  expect_equal(
    reviser:::kk_sym_mul(c("0", "(F0)"), c("(x1)", "(x2)")),
    c("0", "(F0) * (x2)")
  )
})

test_that("kk_sym_add drops an identity-zero side", {
  expect_equal(reviser:::kk_sym_add("0", "(x1)"), "(x1)")
  expect_equal(reviser:::kk_sym_add("(x1)", "0"), "(x1)")
  expect_equal(reviser:::kk_sym_add("(0)", "(0)"), "(0)")
  expect_equal(reviser:::kk_sym_add("(x1)", "(x2)"), "(x1) + (x2)")
})

test_that("kk_sym_sub cancels equal operands and simplifies zeros", {
  expect_equal(reviser:::kk_sym_sub("(x1)", "(x1)"), "0")
  expect_equal(reviser:::kk_sym_sub("0", "(0)"), "0")
  expect_equal(reviser:::kk_sym_sub("0", "(G0_0)"), " - (G0_0)")
  expect_equal(reviser:::kk_sym_sub("(x1)", "0"), "(x1)")
  expect_equal(reviser:::kk_sym_sub("(x1)", "(x2)"), "(x1) - (x2)")
})

test_that("kk_sym_mx computes a symbolic matrix-vector product", {
  mat <- matrix(c("0", "1", "0", "0", "0", "1", "0", "0", "F0"),
    nrow = 3, byrow = TRUE
  )
  vec <- c("z3", "z2", "z1")

  expect_equal(
    reviser:::kk_sym_mx(mat, vec),
    c("(1) * (z2)", "(1) * (z1)", "(F0) * (z1)")
  )

  all_zero <- matrix("0", nrow = 1, ncol = 2)
  expect_equal(reviser:::kk_sym_mx(all_zero, c("x1", "x2")), "0")
})

test_that("kk_sym_diff_mat subtracts a symbolic matrix from a numeric one", {
  II <- diag(2)
  GG <- matrix(c("1", "0", "G1_0", "G0_0"), nrow = 2, byrow = TRUE)

  expect_equal(
    reviser:::kk_sym_diff_mat(II, GG),
    matrix(c("1 - (1)", "0", " - (G1_0)", "1 - (G0_0)"),
      nrow = 2, byrow = TRUE
    )
  )
})

test_that("kk_sym_prod_mat multiplies two symbolic matrices elementwise", {
  m1 <- matrix(c("1 - (1)", "0", " - (G1_0)", "1 - (G0_0)"),
    nrow = 2, byrow = TRUE
  )
  m2 <- matrix(c("0", "1", "0", "F0"), nrow = 2, byrow = TRUE)

  expect_equal(
    reviser:::kk_sym_prod_mat(m1, m2),
    matrix(c("0", "0", "0", "(1 - (G0_0)) * (F0)"), nrow = 2, byrow = TRUE)
  )
})

test_that("kk_equations produces the expected formulas for a KK model", {
  km <- kk_matrices(e = 1, model = "KK", type = "character")
  eqs <- reviser:::kk_equations(km)

  expect_equal(
    deparse1(eqs$eq1),
    "release_1_lag_0 ~ (F0) * (release_1_lag_1)"
  )
  expect_equal(
    deparse1(eqs$eq2),
    paste0(
      "release_0_lag_0 ~ ((1 - (G0_0)) * (F0)) * (release_0_lag_1) + ",
      "(G0_1) * (release_1_lag_1) + (G0_0) * (release_1_lag_0)"
    )
  )
})

test_that("kk_equations produces the expected formulas for a Classical model", {
  km <- kk_matrices(e = 1, model = "Classical", type = "character")
  eqs <- reviser:::kk_equations(km)

  expect_equal(
    deparse1(eqs$eq1),
    "release_1_lag_0 ~ (F0) * (release_1_lag_1)"
  )
  expect_equal(
    deparse1(eqs$eq2),
    paste0(
      "release_0_lag_0 ~ ((1 - (1)) * (F0)) * (release_0_lag_1) + ",
      "(1) * (release_1_lag_0)"
    )
  )
})

# ===== Tests for kk_to_ss =====

test_that("kk_to_ss converts KK matrices to state-space form", {
  # For e=1, KK model G parameters: G(e-i)_(e-j+1)
  # i=1, j=1,2: G0_1, G0_0
  params <- c(
    F0 = 0.8,
    G0_1 = 0.5,
    G0_0 = 0.4,
    v0 = 0.1,
    eps0 = 0.05
  )

  matrices <- kk_matrices(
    e = 1,
    model = "KK",
    params = params,
    type = "numeric"
  )
  ss_mat <- kk_to_ss(matrices$FF, matrices$GG, matrices$V, matrices$W)

  expect_true("Z" %in% names(ss_mat))
  expect_true("Tmat" %in% names(ss_mat))
  expect_true("H" %in% names(ss_mat))
  expect_true("Q" %in% names(ss_mat))
  expect_true("R" %in% names(ss_mat))
})

test_that("kk_to_ss creates correct dimensions", {
  # For e=2, KK model G parameters: G(e-i)_(e-j+1)
  # i=1, j=1,2,3: G1_2, G1_1, G1_0
  # i=2, j=1,2,3: G0_2, G0_1, G0_0
  params <- c(
    F0 = 0.8,
    G1_2 = 0.5, G1_1 = 0.4, G1_0 = 0.3,
    G0_2 = 0.4, G0_1 = 0.3, G0_0 = 0.2,
    v0 = 0.1,
    eps1 = 0.05, eps0 = 0.03
  )

  matrices <- kk_matrices(
    e = 2,
    model = "KK",
    params = params,
    type = "numeric"
  )
  ss_mat <- kk_to_ss(matrices$FF, matrices$GG, matrices$V, matrices$W)

  e <- 2
  expect_equal(dim(ss_mat$Z), c(e + 1, 2 * (e + 1)))
  expect_equal(dim(ss_mat$Tmat), c(2 * (e + 1), 2 * (e + 1)))
  expect_equal(dim(ss_mat$Q), c(2 * (e + 1), 2 * (e + 1)))
})

# ===== Tests for print.kk_model =====

test_that("print.kk_model produces output", {
  result <- fit_kk_ols()

  output <- utils::capture.output(print(result))

  expect_gt(length(output), 0)
  expect_true(any(grepl("Kishor-Koenig Model", output)))
  expect_true(any(grepl("Convergence", output)))
})

test_that("print.kk_model returns invisibly", {
  result <- fit_kk_ols()

  returned <- print(result)
  expect_identical(returned, result)
})

# ===== Tests for summary.kk_model =====

test_that("summary.kk_model produces detailed output", {
  result <- fit_kk_mle()

  output <- utils::capture.output(summary(result))

  expect_gt(length(output), 0)
  expect_true(any(grepl("Kishor-Koenig Model", output)))
  expect_true(any(grepl("Log-likelihood", output)))
  expect_true(any(grepl("AIC", output)))
  expect_true(any(grepl("BIC", output)))
  expect_true(any(grepl("Parameter Estimates", output)))
})

test_that("summary.kk_model returns invisibly", {
  result <- fit_kk_ols()

  returned <- summary(result)
  expect_identical(returned, result)
})

test_that("kk_nowcast records the estimated specification", {
  # Regression test: the fitted object used to discard the `model` argument,
  # so print/summary could not report which specification was estimated.
  specs <- list(
    "Kishor-Koenig" = "Kishor-Koenig",
    "KK" = "Kishor-Koenig",
    "Howrey" = "Howrey",
    "Classical" = "Classical"
  )

  for (arg in names(specs)) {
    result <- kk_fit(
      paste0("spec-", arg),
      df = df_small,
      e = 1,
      h = 0,
      model = arg,
      method = "OLS"
    )

    expect_identical(result$model_type, specs[[arg]])

    output <- utils::capture.output(summary(result))
    expect_true(any(grepl(
      paste0("Specification: ", specs[[arg]]),
      output,
      fixed = TRUE
    )))
  }
})

test_that("summary.kk_model distinguishes Howrey from Classical", {
  howrey <- utils::capture.output(summary(kk_fit(
    "spec-Howrey",
    df = df_small, e = 1, h = 0, model = "Howrey", method = "OLS"
  )))
  classical <- utils::capture.output(summary(kk_fit(
    "spec-Classical",
    df = df_small, e = 1, h = 0, model = "Classical", method = "OLS"
  )))

  expect_false(identical(howrey, classical))
})

# ===== Tests for plot.kk_model =====

test_that("plot.kk_model returns ggplot object", {
  result <- fit_kk_ols_forecast()

  p <- plot(result)
  expect_s3_class(p, "ggplot")
})

test_that("plot.kk_model handles different states", {
  result <- fit_kk_ols_forecast()

  # Get available states
  states <- unique(result$states$state)

  for (state_name in states) {
    p <- plot(result, state = state_name)
    expect_s3_class(p, "ggplot")
  }
})

test_that("plot.kk_model handles filtered vs smoothed", {
  result <- fit_kk_ols_forecast()

  p_filtered <- plot(result, type = "filtered")
  p_smoothed <- plot(result, type = "smoothed")

  expect_s3_class(p_filtered, "ggplot")
  expect_s3_class(p_smoothed, "ggplot")
})

# ===== Integration Tests =====

test_that("full workflow with all components", {
  result <- fit_kk_ols_forecast()

  # Test structure
  expect_s3_class(result, "kk_model")

  # Test methods work
  output_summary <- utils::capture.output(summary(result))
  expect_gt(length(output_summary), 0)

  output_print <- utils::capture.output(print(result))
  expect_gt(length(output_print), 0)

  p <- plot(result)
  expect_s3_class(p, "ggplot")

  # Test components
  expect_true(result$convergence %in% c(0, 1))
  expect_true("Parameter" %in% colnames(result$params))
  expect_true("Estimate" %in% colnames(result$params))
  expect_true("Std.Error" %in% colnames(result$params))
})

test_that("different models produce different results", {
  result_kk <- fit_kk_ols()
  result_howrey <- fit_kk_howrey()
  result_classical <- fit_kk_classical()

  # Different number of parameters
  expect_gt(nrow(result_kk$params), nrow(result_howrey$params))
  expect_gt(nrow(result_howrey$params), nrow(result_classical$params))
})

test_that("different methods produce consistent estimates", {
  result_ols <- fit_kk_ols()
  result_sur <- fit_kk_sur()

  # Both should have same parameter names
  expect_equal(
    result_ols$params$Parameter,
    result_sur$params$Parameter
  )
})

# ===== Edge Cases =====

test_that("kk_nowcast handles minimal data", {
  df_minimal <- df_wide_kk[1:12, 1:3] # 12 obs, 2 releases (e=1)

  result <- kk_nowcast(
    df = df_minimal,
    e = 1,
    method = "OLS",
    solver_options = list(trace = 0)
  )

  expect_s3_class(result, "kk_model")
})

test_that("kk_nowcast handles data with NAs at beginning", {
  df_na <- df_small
  df_na[1:3, 2] <- NA

  result <- kk_nowcast(
    df = df_na,
    e = 1,
    method = "OLS",
    solver_options = list(trace = 0)
  )

  expect_s3_class(result, "kk_model")
})

test_that("kk_nowcast handles extreme values", {
  df_extreme <- df_small
  df_extreme[, -1] <- df_extreme[, -1] * 1e3

  result <- kk_nowcast(
    df = df_extreme,
    e = 1,
    method = "OLS",
    solver_options = list(trace = 0)
  )

  expect_s3_class(result, "kk_model")
})

test_that("kk_nowcast handles high frequency data", {
  # Quarterly data
  df_quarterly <- data.frame(
    time = seq.Date(as.Date("2020-01-01"), by = "quarter", length.out = 20),
    release_0 = rnorm(20, 100, 10),
    release_1 = rnorm(20, 100, 8)
  )

  result <- kk_nowcast(
    df = df_quarterly,
    e = 1,
    method = "OLS",
    solver_options = list(trace = 0)
  )

  expect_s3_class(result, "kk_model")
})

test_that("kk_nowcast model matrices have correct properties", {
  result <- kk_nowcast(
    df = df_small,
    e = 1,
    method = "OLS",
    solver_options = list(trace = 0)
  )

  # Check FF is square
  expect_equal(nrow(result$kk_model_mat$FF), ncol(result$kk_model_mat$FF))

  # Check GG is square
  expect_equal(nrow(result$kk_model_mat$GG), ncol(result$kk_model_mat$GG))

  # Check V is square
  expect_equal(nrow(result$kk_model_mat$V), ncol(result$kk_model_mat$V))

  # Check W is square
  expect_equal(nrow(result$kk_model_mat$W), ncol(result$kk_model_mat$W))
})

test_that("kk_nowcast information criteria are calculated correctly for MLE", {
  result <- fit_kk_mle()

  expect_true(is.finite(result$aic))
  expect_true(is.finite(result$bic))
  expect_true(is.finite(result$loglik))

  # BIC should penalize more than AIC
  expect_gt(result$bic, result$aic)
})

test_that("kk_nowcast confidence intervals have correct coverage", {
  result <- fit_kk_ols()

  # Check that lower < estimate < upper
  states <- result$states
  expect_true(all(states$lower <= states$estimate, na.rm = TRUE))
  expect_true(all(states$estimate <= states$upper, na.rm = TRUE))
})

# ===== Tests for standard S3 methods on kk_model =====

test_that("kk_model supports the standard extractor generics", {
  result <- fit_kk_mle()

  estimates <- coef(result)
  expect_type(estimates, "double")
  expect_identical(names(estimates), result$params$Parameter)
  expect_identical(unname(estimates), result$params$Estimate)

  covariance <- vcov(result)
  expect_true(is.matrix(covariance))
  expect_identical(rownames(covariance), result$params$Parameter)
  expect_identical(colnames(covariance), result$params$Parameter)
  expect_identical(nrow(covariance), length(estimates))

  expect_identical(nobs(result), as.integer(result$n_ic))
})

test_that("logLik.kk_model makes AIC and BIC reproduce the reported values", {
  result <- fit_kk_mle()

  ll <- logLik(result)
  expect_s3_class(ll, "logLik")
  expect_equal(as.numeric(ll), result$loglik)
  expect_identical(attr(ll, "df"), result$n_param)
  expect_identical(attr(ll, "nobs"), result$n_ic)

  # The whole point of carrying df and nobs: the stats generics must agree
  # with the numbers summary() prints.
  expect_equal(AIC(result), result$aic)
  expect_equal(BIC(result), result$bic)
})

test_that("fitted, residuals and predict on kk_model are consistent", {
  result <- fit_kk_ols_forecast()

  fit_vals <- fitted(result)
  expect_s3_class(fit_vals, "tbl_df")
  expect_named(fit_vals, c("time", "estimate", "lower", "upper"))
  expect_gt(nrow(fit_vals), 0)
  expect_true(all(!is.na(fit_vals$estimate)))

  resid <- residuals(result)
  expect_named(resid, c("time", "residual"))
  expect_identical(nrow(resid), nrow(fit_vals))

  # residual = observed efficient release - fitted latent state
  target <- result$data[[paste0("release_", result$e)]]
  observed <- result$data$time
  merged <- merge(
    data.frame(time = observed, obs = target),
    as.data.frame(fit_vals),
    by = "time"
  )
  expect_equal(
    resid$residual[order(resid$time)],
    (merged$obs - merged$estimate)[order(merged$time)]
  )

  forecasts <- predict(result)
  expect_named(forecasts, c("time", "estimate", "lower", "upper"))
  expect_identical(nrow(forecasts), 3L) # h = 3
  expect_true(all(forecasts$time > max(fit_vals$time)))
})

test_that("predict.kk_model returns no rows when fitted with h = 0", {
  expect_identical(nrow(predict(fit_kk_ols())), 0L)
})

test_that("states.kk_model filters and validates", {
  result <- fit_kk_ols()

  smoothed <- states(result)
  expect_true(all(smoothed$filter == "smoothed"))

  filtered <- states(result, filter = "filtered")
  expect_true(all(filtered$filter == "filtered"))

  both <- states(result, filter = "all")
  expect_identical(nrow(both), nrow(smoothed) + nrow(filtered))
  expect_identical(both, result$states)

  one <- states(result, state = signal_state(result))
  expect_identical(unique(one$state), signal_state(result))

  expect_error(states(result, state = "not_a_state"), "Unknown state")
})

test_that("kk_model accessors error informatively when data is absent", {
  result <- kk_fit(
    "no-states",
    df = df_small,
    e = 1,
    h = 0,
    model = "KK",
    method = "OLS",
    solver_options = list(return_states = FALSE)
  )

  expect_error(states(result), "return_states = FALSE")
  # Parameter-level accessors still work without state estimates.
  expect_gt(length(coef(result)), 0)
})

Try the reviser package in your browser

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

reviser documentation built on Sept. 14, 2026, 9:09 a.m.